diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0bc6ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +nitrostack.log diff --git a/examples/__pycache__/calculator_server.cpython-312.pyc b/examples/__pycache__/calculator_server.cpython-312.pyc deleted file mode 100644 index f0de664..0000000 Binary files a/examples/__pycache__/calculator_server.cpython-312.pyc and /dev/null differ diff --git a/gap_analysis.md b/gap_analysis.md new file mode 100644 index 0000000..7d13b41 --- /dev/null +++ b/gap_analysis.md @@ -0,0 +1,85 @@ +# Gap Analysis: NitroStack Python SDK vs. Requirements (Updated) + +This document provides a comprehensive evaluation of the `nitrostack-python-sdk-fork` repository against the design expectations and requirements specified in the `docs` folder. It outlines key implementation gaps, bugs, and missing features, showing which have been resolved. + +--- + +## 1. Modules & Dependency Injection (DI) +**Reference:** [01_introduction_and_di.md](file:///c:/Users/gauta/OneDrive/Desktop/nitrostack-codebases/docs/01_introduction_and_di.md) + +* **Auto-Resolving Constructor Arguments**: **[RESOLVED]** + * *Actual:* `di.py` uses `inspect.signature` and `typing.get_type_hints` to auto-resolve type-annotated constructor dependencies. +* **DI Thread-Safety**: **[RESOLVED]** + * *Actual:* `DIContainer` uses locking mechanisms (`threading.Lock` and `threading.RLock`) to ensure safe resolution in multi-threaded contexts. +* **Custom Token Mapping via `provide`**: **[RESOLVED]** + * *Actual:* Support for registering services under custom token names or interfaces is fully implemented via `@injectable(provide="Token")`. +* **Circular Dependency Detection**: **[RESOLVED]** + * *Actual:* The container tracks resolution paths and throws an explicit `CircularDependencyError` defined in `errors.py`. + +--- + +## 2. MCP Primitives (Tools, Resources, Prompts) +**Reference:** [02_mcp_primitives.md](file:///c:/Users/gauta/OneDrive/Desktop/nitrostack-codebases/docs/02_mcp_primitives.md) + +* **Omitting Description / Docstring Parsing**: **[RESOLVED]** + * *Actual:* The `@tool`, `@prompt`, and `@resource` decorators make the `description` argument optional and inspect function docstrings (`func.__doc__`) to extract the description if omitted. +* **Structured Validation Errors**: **[RESOLVED]** + * *Actual:* Validation errors (Pydantic and custom) are caught and translated into structured MCP protocol validation error blocks returning `types.CallToolResult(isError=True)`. + +--- + +## 3. Request Execution Pipeline +**Reference:** [03_execution_pipeline.md](file:///c:/Users/gauta/OneDrive/Desktop/nitrostack-codebases/docs/03_execution_pipeline.md) + +* **Middleware Signature / Lambda Crash**: **[RESOLVED]** + * *Actual:* Middleware execution chain is constructed using dynamic async steps that correctly support ASGI-like `call_next(context)` and parameterless `call_next()` invocations, propagating context changes downstream. +* **Guard Failure Handling**: **[RESOLVED]** + * *Actual:* Denials from guards (raising `PermissionError`) are caught by the translation layer and return an access denied result block with `isError=True`. + +--- + +## 4. Security & Authentication +**Reference:** [04_security_and_auth.md](file:///c:/Users/gauta/OneDrive/Desktop/nitrostack-codebases/docs/04_security_and_auth.md) + +* **Custom Cryptographic Implementations (Security Flaw - Outstanding)**: + * *Requirement:* The SDK must wrap standard, audited packages like `PyJWT`, `cryptography`, or `authlib` to manage tokens instead of writing custom cryptographic functions. + * *Actual:* In `nitrostack/auth/jwt.py`, signature creation and verification are implemented from scratch using Python's standard `hmac`, `hashlib`, and `base64` modules. This custom implementation only supports HS256, lacks compliance checks, is prone to cryptographic vulnerabilities, and does not support standard production key validation methods. +* **Missing Dependencies for JWKS (Missing / Packaging Defect - Outstanding)**: + * *Requirement:* Use standard Python packages for JWKS and signature checking. + * *Actual:* In `nitrostack/auth/oauth.py`, the JWKS verification code tries to import `jwt` (PyJWT) and use `jwt.PyJWKClient`. However, neither `PyJWT` nor `cryptography` are listed as dependencies in `pyproject.toml` or `requirements.txt`. If JWKS is enabled, invoking a protected endpoint results in a `ModuleNotFoundError`. +* **Asynchronous HTTP Client for OAuth Introspection (Missing - Outstanding)**: + * *Requirement:* Validation of authorization tokens against remote Discovery endpoints must use asynchronous HTTP clients like `httpx` instead of blocking synchronous HTTP clients. + * *Actual:* In `nitrostack/auth/oauth.py`, `introspect_token` constructs a request and calls the synchronous `urllib.request.urlopen` standard library utility. Even though it is run within an executor (`loop.run_in_executor`), it does not use a non-blocking asynchronous library like `httpx` as required. `httpx` is not specified as a project dependency. + +--- + +## 5. Async Background Tasks +**Reference:** [05_task_management.md](file:///c:/Users/gauta/OneDrive/Desktop/nitrostack-codebases/docs/05_task_management.md) + +* **Task Cancellation & `asyncio.CancelledError` (Bug - Outstanding)**: + * *Requirement:* If a client requests cancellation, `asyncio.CancelledError` is raised inside the running task, triggering standard cleanups and context manager releases. + * *Actual:* In `nitrostack/core/task.py`, the `cancel_task` method only updates the internal metadata state (`entry.is_cancelled = True`, `entry.status = "cancelled"`). The task execution runner does not keep a reference to the `asyncio.Task` object spawned by `asyncio.create_task()`, meaning it cannot cancel the running asyncio task. The background python code continues running to completion in the background, and no `asyncio.CancelledError` is ever raised. +* **Synchronous Blocking Operations Support (Missing - Outstanding)**: + * *Requirement:* Direct support for running synchronous tool functions in a separate thread pool executor (e.g. via `asyncio.to_thread`) to avoid event loop starvation. + * *Actual:* In `pipeline.py`'s `run_pipeline`, the handler is always directly awaited: `await handler(...)`. If a synchronous (blocking) function is registered as a tool, this call fails with a `TypeError` because synchronous functions are not awaitable. + * Furthermore, the `@cache` and `@rate_limit` decorators in `additional_decorators.py` are hardcoded to be asynchronous and explicitly await the wrapped function (`await func(...)`), which fails if they wrap a synchronous function. + +--- + +## 6. CLI Scaffolding & Testing Harness +**Reference:** [06_cli_and_testing.md](file:///c:/Users/gauta/OneDrive/Desktop/nitrostack-codebases/docs/06_cli_and_testing.md) + +* **Hardcoded CLI Target in `nitrostack-py dev`**: **[RESOLVED]** + * *Actual:* CLI subcommands correctly accept a custom `--file` argument, defaulting to `"main.py"`. +* **Missing Out-of-the-Box Pytest Fixture (Missing - Outstanding)**: + * *Requirement:* Provide a pre-configured `pytest` fixture (`nitro_client`) that instantiates an in-process mock transport. + * *Actual:* The SDK only exports the `NitroTestingModule` class. No pytest plugins, hooks, or fixtures (`nitro_client`) are defined or packaged by the SDK. + +--- + +## 7. UI Widgets & Next.js Adapters +**Reference:** [07_widgets_integration.md](file:///c:/Users/gauta/OneDrive/Desktop/nitrostack-codebases/docs/07_widgets_integration.md) + +* **Next.js Static HTML Inlining (Missing - Outstanding)**: + * *Requirement:* Compile a Next.js directory into a single HTML bundle (inline CSS/JS) using `compile_next_widget`. + * *Actual:* The entire frontend adaptation and compilation pipeline is missing. There is no `ui_next` module or `compile_next_widget` helper in the codebase. diff --git a/nitrostack.log b/nitrostack.log deleted file mode 100644 index fbc40c6..0000000 --- a/nitrostack.log +++ /dev/null @@ -1,65 +0,0 @@ -2026-06-10 13:22:50,079 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-10 13:23:13,039 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-10 13:23:13,044 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-10 13:23:13,046 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-10 13:23:39,823 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-10 13:23:39,827 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-10 13:23:39,828 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-10 13:24:09,252 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-10 13:24:09,260 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-10 13:24:09,261 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-10 13:24:35,992 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-10 13:24:35,997 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-10 13:24:35,999 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-10 13:25:00,299 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-10 13:25:00,303 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-10 13:25:00,304 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-10 13:25:32,026 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-10 13:25:32,032 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-10 13:25:32,033 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-10 13:35:53,144 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-10 13:35:53,151 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-10 13:35:53,153 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-10 13:36:34,272 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-10 13:36:34,283 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-10 13:36:34,285 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-10 14:33:53,465 [INFO] (nitrostack): Executing calculation: 10.0 divide 15.0 -2026-06-10 14:34:00,907 [INFO] (nitrostack): Executing calculation: 10.0 add 15.0 -2026-06-10 14:34:03,731 [INFO] (nitrostack): Executing calculation: 10.0 add 15.0 -2026-06-10 14:37:22,534 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-10 14:37:22,538 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-10 14:37:22,540 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-10 14:41:33,833 [INFO] (nitrostack): Executing calculation: 10.0 add 20.0 -2026-06-10 14:48:06,730 [INFO] (nitrostack): Executing calculation: 10.0 add 20.0 -2026-06-10 14:52:34,751 [INFO] (nitrostack): Converting temperature: 35.0 from celsius to fahrenheit -2026-06-10 14:52:39,316 [INFO] (nitrostack): Converting temperature: 35.0 from celsius to celsius -2026-06-10 14:52:42,765 [INFO] (nitrostack): Converting temperature: 35.0 from celsius to kelvin -2026-06-10 15:07:19,747 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-10 15:07:19,752 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-10 15:07:19,753 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-10 15:20:07,388 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-10 15:20:07,393 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-10 15:20:07,395 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-10 15:39:46,729 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-10 15:39:46,738 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-10 15:39:46,740 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-10 15:45:21,942 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-10 15:45:21,947 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-10 15:45:21,948 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-12 11:55:23,249 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-12 11:55:23,259 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-12 11:55:23,262 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-12 13:05:52,734 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-12 13:05:52,756 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-12 13:05:52,760 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-12 13:32:04,654 [INFO] (nitrostack): Executing calculation: 10.0 add 20.0 -2026-06-17 12:46:21,443 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-17 12:46:21,452 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-17 12:46:21,454 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit -2026-06-18 12:25:18,921 [INFO] (nitrostack): Executing calculation: 10.0 multiply 20.0 -2026-06-18 12:25:24,581 [INFO] (nitrostack): Converting temperature: 35.0 from celsius to celsius -2026-06-18 12:25:28,765 [INFO] (nitrostack): Converting temperature: 35.0 from celsius to fahrenheit -2026-06-18 12:26:45,327 [INFO] (nitrostack): Converting temperature: 35.0 from celsius to kelvin -2026-06-18 13:27:32,721 [INFO] (nitrostack): Executing calculation: 12.5 add 3.5 -2026-06-18 13:27:32,729 [INFO] (nitrostack): Executing calculation: 10.0 divide 0.0 -2026-06-18 13:27:32,731 [INFO] (nitrostack): Converting temperature: 100.0 from celsius to fahrenheit diff --git a/nitrostack/__pycache__/__init__.cpython-312.pyc b/nitrostack/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index d4322cd..0000000 Binary files a/nitrostack/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/auth/__pycache__/api_key.cpython-312.pyc b/nitrostack/auth/__pycache__/api_key.cpython-312.pyc deleted file mode 100644 index f3ae70a..0000000 Binary files a/nitrostack/auth/__pycache__/api_key.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/auth/__pycache__/config.cpython-312.pyc b/nitrostack/auth/__pycache__/config.cpython-312.pyc deleted file mode 100644 index f7f1b56..0000000 Binary files a/nitrostack/auth/__pycache__/config.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/auth/__pycache__/jwt.cpython-312.pyc b/nitrostack/auth/__pycache__/jwt.cpython-312.pyc deleted file mode 100644 index ebacbe9..0000000 Binary files a/nitrostack/auth/__pycache__/jwt.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/auth/__pycache__/oauth.cpython-312.pyc b/nitrostack/auth/__pycache__/oauth.cpython-312.pyc deleted file mode 100644 index f451c9a..0000000 Binary files a/nitrostack/auth/__pycache__/oauth.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index d673dfe..dea2e2c 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -841,10 +841,16 @@ def print_banner(): ╚══════════════════════════════════════════════════════════╝\033[0m""" print(banner) -def init_project(name: str, template: str = None): +def init_project(name: str = None, template: str = None, description: str = None, author: str = None, skip_install: bool = False): print_banner() - # 1. Overwrite check + # 1. Prompt for project name if not provided + if not name: + sys.stdout.write("\033[32m? \033[1;37mProject name:\033[0m [my-mcp-server]: ") + sys.stdout.flush() + name = sys.stdin.readline().strip() or "my-mcp-server" + + # Overwrite check if os.path.exists(name): sys.stdout.write(f"\033[32m? \033[1;37mDirectory '{name}' already exists. Overwrite?\033[0m (Yes/No) [No]: ") sys.stdout.flush() @@ -887,13 +893,15 @@ def init_project(name: str, template: str = None): template = "flight-booking" # 3. Description and Author - sys.stdout.write("\033[32m? \033[1;37mDescription:\033[0m [My awesome MCP server]: ") - sys.stdout.flush() - description = sys.stdin.readline().strip() or "My awesome MCP server" + if description is None: + sys.stdout.write("\033[32m? \033[1;37mDescription:\033[0m [My awesome MCP server]: ") + sys.stdout.flush() + description = sys.stdin.readline().strip() or "My awesome MCP server" - sys.stdout.write("\033[32m? \033[1;37mAuthor:\033[0m [developer]: ") - sys.stdout.flush() - author = sys.stdin.readline().strip() or "developer" + if author is None: + sys.stdout.write("\033[32m? \033[1;37mAuthor:\033[0m [developer]: ") + sys.stdout.flush() + author = sys.stdin.readline().strip() or "developer" # 4. Copy template directory import nitrostack @@ -907,7 +915,6 @@ def init_project(name: str, template: str = None): shutil.copytree(template_src_dir, name) print("\n\033[32m✓\033[0m Project created") - print("\033[32m✓\033[0m Dependencies installed") # 5. Update .env file env_path = os.path.join(name, ".env") @@ -945,16 +952,19 @@ def init_project(name: str, template: str = None): pass # 7. Run npm install inside widgets directory - widgets_dir = os.path.join(name, "src", "widgets") - if os.path.exists(widgets_dir): - print("Installing widget dependencies...") - try: - subprocess.run(["npm", "--version"], shell=True, capture_output=True, check=True) - subprocess.run(["npm", "install"], cwd=widgets_dir, shell=True, check=True) - print("\033[32m✓\033[0m Widget dependencies installed\n") - except Exception as e: - print(f"Warning: Failed to install widget dependencies: {e}") - print("Please run 'npm install' inside 'src/widgets' manually.\n") + if not skip_install: + widgets_dir = os.path.join(name, "src", "widgets") + if os.path.exists(widgets_dir): + print("Installing widget dependencies...") + try: + subprocess.run(["npm", "--version"], shell=True, capture_output=True, check=True) + subprocess.run(["npm", "install"], cwd=widgets_dir, shell=True, check=True) + print("\033[32m✓\033[0m Widget dependencies installed\n") + except Exception as e: + print(f"Warning: Failed to install widget dependencies: {e}") + print("Please run 'npm install' inside 'src/widgets' manually.\n") + else: + print("Skipping dependencies installation (--skip-install)\n") # Success Card abs_path = os.path.abspath(name) @@ -975,14 +985,17 @@ def init_project(name: str, template: str = None): print(" See \033[34mOAUTH_SETUP.md\033[0m for provider guides") else: print(" 2. Configure environment variables in \033[34m.env\033[0m") - print(" 3. Start development server: \033[34mnitrostack-py dev\033[0m (or `python -m nitrostack.cli.main dev`)") - print(" 4. Start NitroStudio dashboard: \033[34mnitrostack-studio\033[0m (or `python -m nitrostack.studio`)") + if skip_install: + print(" 3. Install dependencies: \033[34mnitrostack-py install\033[0m") + print(" 4. Start development server: \033[34mnitrostack-py dev\033[0m") + else: + print(" 3. Start development server: \033[34mnitrostack-py dev\033[0m (or `python -m nitrostack.cli.main dev`)") + print(" 4. Start NitroStudio dashboard: \033[34mnitrostack-studio\033[0m (or `python -m nitrostack.studio`)") print("\nHappy coding! 🚀\n") -def run_dev(): - target = "main.py" +def run_dev(target="main.py", port=3001): if not os.path.exists(target): - print("Error: main.py not found in current directory.") + print(f"Error: {target} not found in current directory.") sys.exit(1) print(f"Starting hot-reload development server for {target}...") @@ -992,11 +1005,11 @@ def run_dev(): # Check if Next.js widgets are present widgets_dir = os.path.join(os.getcwd(), "src", "widgets") if os.path.exists(widgets_dir) and os.path.exists(os.path.join(widgets_dir, "package.json")): - print("Starting widget development server on port 3001...") + print(f"Starting widget development server on port {port}...") try: - # Spawn npm run dev -- --port 3001 + # Spawn npm run dev -- --port widgets_process = subprocess.Popen( - ["npm", "run", "dev", "--", "--port", "3001"], + ["npm", "run", "dev", "--", "--port", str(port)], cwd=widgets_dir, shell=True ) @@ -1101,41 +1114,520 @@ def get_mtimes(): print("\nStopping development server...") cleanup() -def run_start(): - target = "main.py" +def run_start(target="main.py", port=None): if not os.path.exists(target): - print("Error: main.py not found in current directory.") + print(f"Error: {target} not found in current directory.") sys.exit(1) print(f"Starting production server for {target}...") env = os.environ.copy() env["PYTHONPATH"] = os.path.abspath(".") + if port is not None: + env["PORT"] = str(port) try: subprocess.run([sys.executable, target], env=env) except KeyboardInterrupt: print("\nStopping server...") -def generate_tool(name: str): - filename = f"{name}_tool.py" - if os.path.exists(filename): - print(f"Error: File '{filename}' already exists.") +def run_install(skip_widgets=False, production=False): + print("Installing dependencies...") + # 1. Install Python dependencies in current directory + if os.path.exists("requirements.txt"): + print("Installing Python dependencies from requirements.txt...") + try: + subprocess.run([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], check=True) + print("\033[32m✓\033[0m Python dependencies installed") + except Exception as e: + print(f"Error: Failed to install Python dependencies: {e}") + elif os.path.exists("pyproject.toml"): + print("Installing Python dependencies from pyproject.toml...") + try: + if os.path.exists("poetry.lock"): + subprocess.run(["poetry", "install"], shell=True, check=True) + else: + subprocess.run([sys.executable, "-m", "pip", "install", "."], check=True) + print("\033[32m✓\033[0m Python dependencies installed") + except Exception as e: + print(f"Error: Failed to install Python dependencies: {e}") + + # 2. Install widget dependencies + if not skip_widgets: + widgets_dir = os.path.join(os.getcwd(), "src", "widgets") + if os.path.exists(widgets_dir) and os.path.exists(os.path.join(widgets_dir, "package.json")): + print("Installing widget dependencies...") + try: + cmd = ["npm", "install", "--production"] if production else ["npm", "install"] + subprocess.run(cmd, cwd=widgets_dir, shell=True, check=True) + print("\033[32m✓\033[0m Widget dependencies installed") + except Exception as e: + print(f"Error: Failed to install widget dependencies: {e}") + +def run_build(output="dist"): + print("Building project...") + widgets_dir = os.path.join(os.getcwd(), "src", "widgets") + if os.path.exists(widgets_dir) and os.path.exists(os.path.join(widgets_dir, "package.json")): + print("Building Next.js widgets...") + try: + subprocess.run(["npm", "run", "build"], cwd=widgets_dir, shell=True, check=True) + print("\033[32m✓\033[0m Widgets built successfully") + except Exception as e: + print(f"Error building widgets: {e}") + sys.exit(1) + else: + print("No widgets directory found to build.") + +def run_upgrade(dry_run=False, latest=False): + if dry_run: + print("Dry run: Checking for upgrades...") + print("Would run: pip install --upgrade nitrostack") + return + + print("Upgrading nitrostack...") + try: + cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "nitrostack"] + if latest: + cmd.append("--force-reinstall") + subprocess.run(cmd, check=True) + print("\033[32m✓\033[0m nitrostack upgraded successfully") + except Exception as e: + print(f"Error upgrading nitrostack: {e}") sys.exit(1) - camel_name = "".join(part.capitalize() for part in name.split("_")) - content = TOOL_TEMPLATE.format(name=name, camel_name=camel_name) - with open(filename, "w", encoding="utf-8") as f: - f.write(content) - print(f"Generated tool boilerplate in '{filename}'") -def generate_module(name: str): - filename = f"{name}_module.py" - if os.path.exists(filename): - print(f"Error: File '{filename}' already exists.") +# Generator boilerplate templates mapping +TEMPLATES = { + "middleware": """from nitrostack import ExecutionContext +from typing import Callable, Any + +class {camel_name}: + async def use(self, context: ExecutionContext, next_fn: Callable[[], Any]) -> Any: + # Use context.logger instead of print — print breaks JSON-RPC over stdio in MCP mode + context.logger.info(f"[{context.tool_name}] Started") + + result = await next_fn() + + context.logger.info(f"[{context.tool_name}] Completed") + return result +""", + "interceptor": """from nitrostack import ExecutionContext +from typing import Callable, Any +from datetime import datetime + +class {camel_name}: + async def intercept(self, context: ExecutionContext, next_fn: Callable[[], Any]) -> Any: + result = await next_fn() + + # Transform response + return {{ + "success": True, + "data": result, + "timestamp": datetime.utcnow().isoformat() + }} +""", + "pipe": """from nitrostack.core.pipeline import PipeMetadata +from typing import Any + +class {camel_name}: + async def transform(self, value: Any, metadata: PipeMetadata) -> Any: + # Validate + if not value: + raise ValueError("Value is required") + + # Transform + return value +""", + "filter": """from nitrostack import ExecutionContext +from datetime import datetime + +class {camel_name}: + async def catch(self, error: Exception, context: ExecutionContext): + error_message = str(error) + context.logger.error("Exception caught", extra={{ + "tool": context.tool_name, + "error": error_message + }}) + + return {{ + "error": True, + "message": error_message, + "timestamp": datetime.utcnow().isoformat() + }} +""", + "service": """from nitrostack import injectable + +@injectable() +class {camel_name}: + # Add your service methods here + + async def find_one(self, id: str) -> dict: + # TODO: Implement + return {{"id": id}} + + async def find_all(self) -> list: + # TODO: Implement + return [] + + async def create(self, data: dict) -> dict: + # TODO: Implement + return {{"id": "1", **data}} + + async def update(self, id: str, data: dict) -> dict: + # TODO: Implement + return {{"id": id, **data}} + + async def delete(self, id: str) -> None: + # TODO: Implement + pass +""", + "guard": """from nitrostack import ExecutionContext + +class {camel_name}: + async def can_activate(self, context: ExecutionContext) -> bool: + # TODO: Implement your guard logic + + # Example: Check if user has required role + user_role = getattr(context.auth, "role", None) + + if not user_role: + raise ValueError("Authentication required") + + if user_role != "admin": + raise ValueError("Insufficient permissions") + + return True +""", + "health": """import time +from nitrostack import health_check + +class {camel_name}: + def __init__(self): + self.start_time = time.time() + + @health_check("{name}") + def check_health(self) -> bool: + # TODO: Implement health check logic + uptime = time.time() - self.start_time + return uptime >= 0 +""", + "module": """from nitrostack import module +from modules.{name}.{name}_tools import {camel_name}Tools +from modules.{name}.{name}_resources import {camel_name}Resources +from modules.{name}.{name}_prompts import {camel_name}Prompts + +@module( + name="{name}", + controllers=[{camel_name}Tools, {camel_name}Resources, {camel_name}Prompts], + providers=[], + exports=[] +) +class {camel_name}Module: + pass +""", + "tools": """from nitrostack import injectable, tool, ExecutionContext +from pydantic import BaseModel, Field + +class {camel_name}ExampleInput(BaseModel): + id: str = Field(description="ID parameter") + +@injectable() +class {camel_name}Tools: + @tool( + name="{name}_example", + description="Implement your tool description here", + input_schema={camel_name}ExampleInput + ) + async def example_tool(self, input: {camel_name}ExampleInput, context: ExecutionContext): + # TODO: Implement tool logic + context.logger.info(f"Executing tool {name}_example") + return {{"id": input.id, "result": "success"}} +""", + "resources": """from nitrostack import injectable, resource, ExecutionContext + +@injectable() +class {camel_name}Resources: + @resource( + uri="{name}://example", + name="Example Resource", + description="Implement your resource description here", + mime_type="application/json" + ) + async def example_resource(self, context: ExecutionContext) -> dict: + # TODO: Implement resource logic + return {{"example": "data"}} +""", + "prompts": """from nitrostack import injectable, prompt, ExecutionContext + +@injectable() +class {camel_name}Prompts: + @prompt( + name="{name}-help", + description="Implement your prompt description here" + ) + async def help_prompt(self, args: dict, context: ExecutionContext) -> str: + # TODO: Implement prompt logic + return "You are an assistant. Help the user with resources." +""" +} + +def generate_component(type_name: str, name: str, options): + import re + + # Normalize type name + type_name = type_name.lower() + + # Check special case: tool/module compatibility + if type_name == "tool": + type_name = "tools" + + if type_name not in TEMPLATES: + print(f"Error: Invalid generator type '{type_name}'.") + print(f"Supported types: {', '.join(TEMPLATES.keys())}") + sys.exit(1) + + camel_name = "".join(part.capitalize() for part in name.replace("-", "_").split("_")) + snake_name = name.replace("-", "_").lower() + + # Get path mapping + base_path = os.getcwd() + +def generate_types(options): + import ast + import glob + + base_path = os.getcwd() + output_path = getattr(options, "output", None) + if not output_path: + widgets_types_dir = os.path.join(base_path, "src", "widgets", "src", "types") + if os.path.exists(widgets_types_dir): + output_path = os.path.join(widgets_types_dir, "generated-tools.ts") + else: + output_path = os.path.join(base_path, "src", "types", "generated-tools.ts") + + print("Scanning for tool definitions...") + + py_files = [] + for root, dirs, files in os.walk(base_path): + if any(p in root.split(os.sep) for p in ("venv", "env", "__pycache__", ".git", "src")): + continue + for file in files: + if file.endswith(".py") and file != "main.py": + py_files.append(os.path.join(root, file)) + + if not py_files: + print("No Python files found.") + return + + print(f"Found {len(py_files)} Python file(s).") + + tool_types = {} + + for file_path in py_files: + try: + with open(file_path, "r", encoding="utf-8") as f: + source = f.read() + + tree = ast.parse(source) + models = {} + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + inherits_base_model = False + for base in node.bases: + if isinstance(base, ast.Name) and base.id == "BaseModel": + inherits_base_model = True + elif isinstance(base, ast.Attribute) and base.attr == "BaseModel": + inherits_base_model = True + + if inherits_base_model: + fields = {} + for item in node.body: + if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + field_name = item.target.id + field_type = "any" + type_map = { + "str": "string", + "int": "number", + "float": "number", + "bool": "boolean", + "dict": "Record", + "list": "any[]", + "Any": "any" + } + if isinstance(item.annotation, ast.Name): + field_type = type_map.get(item.annotation.id, "any") + elif isinstance(item.annotation, ast.Subscript) and isinstance(item.annotation.value, ast.Name): + outer_type = item.annotation.value.id + if outer_type == "Optional": + if isinstance(item.annotation.slice, ast.Name): + field_type = type_map.get(item.annotation.slice.id, "any") + " | null" + elif outer_type in ("List", "list"): + field_type = "any[]" + elif outer_type in ("Dict", "dict"): + field_type = "Record" + fields[field_name] = field_type + models[node.name] = fields + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + for decorator in node.decorator_list: + is_tool = False + tool_name = None + input_schema_name = None + + if isinstance(decorator, ast.Call): + if isinstance(decorator.func, ast.Name) and decorator.func.id == "tool": + is_tool = True + elif isinstance(decorator.func, ast.Attribute) and decorator.func.attr == "tool": + is_tool = True + + if is_tool: + for kw in decorator.keywords: + if kw.arg == "name" and isinstance(kw.value, ast.Constant): + tool_name = kw.value.value + elif kw.arg == "input_schema" and isinstance(kw.value, ast.Name): + input_schema_name = kw.value.id + + if is_tool and tool_name: + fields = models.get(input_schema_name, {}) + tool_types[tool_name] = fields + except Exception: + pass + + ts_output = f"""/** + * Auto-generated TypeScript types from NitroStack Python tool definitions + * DO NOT EDIT THIS FILE MANUALLY + * + * Generated by: nitrostack-py generate types + * Generated at: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} + */ + +export interface ToolInput {{ + [key: string]: T; +}} + +export interface ToolOutput {{ + [key: string]: T; +}} + +""" + for tool_name, fields in tool_types.items(): + pascal_name = "".join(w.capitalize() for w in tool_name.replace("-", "_").split("_")) + ts_output += f"// {tool_name}\n" + ts_output += f"export interface {pascal_name}Input {{\n" + for f_name, f_type in fields.items(): + ts_output += f" {f_name}: {f_type};\n" + if not fields: + ts_output += " [key: string]: any;\n" + ts_output += "}\n\n" + + if tool_types: + ts_output += f"export type ToolName = { ' | '.join(f'\"{name}\"' for name in tool_types.keys()) };\n\n" + else: + ts_output += "export type ToolName = string;\n\n" + + ts_output += "export interface ToolInputs {\n" + for tool_name in tool_types.keys(): + pascal_name = "".join(w.capitalize() for w in tool_name.replace("-", "_").split("_")) + ts_output += f" '{tool_name}': {pascal_name}Input;\n" + ts_output += "}\n" + + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + f.write(ts_output) + + print(f"\033[32m✓\033[0m Types generated successfully at: {os.path.relpath(output_path, base_path)}") + +def generate_component(type_name: str, name: str, options): + import re + + # Normalize type name + type_name = type_name.lower() + + # Special case for types generation + if type_name == "types": + generate_types(options) + return + + # Check special case: tool/module compatibility + if type_name == "tool": + type_name = "tools" + + if type_name not in TEMPLATES: + print(f"Error: Invalid generator type '{type_name}'.") + print(f"Supported types: {', '.join(TEMPLATES.keys())}, types") + sys.exit(1) + + camel_name = "".join(part.capitalize() for part in name.replace("-", "_").split("_")) + snake_name = name.replace("-", "_").lower() + + # Get path mapping + base_path = os.getcwd() + + # Module specific generation + if type_name in ("module", "tools", "resources", "prompts"): + module_name = getattr(options, "module", None) or snake_name + module_name_snake = module_name.replace("-", "_").lower() + + # Tools/Resources/Prompts should use suffix + suffix_map = { + "module": "module", + "tools": "tools", + "resources": "resources", + "prompts": "prompts" + } + suffix = suffix_map[type_name] + filename = f"{module_name_snake}_{suffix}.py" + + # Put inside modules/ + dir_path = os.path.join(base_path, "modules", module_name_snake) + file_path = os.path.join(dir_path, filename) + else: + # Other types go to their respective root directories + dir_map = { + "middleware": "middleware", + "interceptor": "interceptors", + "pipe": "pipes", + "filter": "filters", + "service": "services", + "guard": "guards", + "health": "health" + } + dir_name = dir_map[type_name] + filename = f"{snake_name}.py" + dir_path = os.path.join(base_path, dir_name) + file_path = os.path.join(dir_path, filename) + + # Custom output path override + if getattr(options, "output", None): + file_path = os.path.abspath(options.output) + dir_path = os.path.dirname(file_path) + + # Create dir if not exists + if not os.path.exists(dir_path): + os.makedirs(dir_path, exist_ok=True) + + # Check if exists and force option + if os.path.exists(file_path) and not getattr(options, "force", False): + print(f"Error: File '{file_path}' already exists. Use --force to overwrite.") sys.exit(1) - camel_name = "".join(part.capitalize() for part in name.split("_")) - content = MODULE_TEMPLATE.format(name=name, camel_name=camel_name) - with open(filename, "w", encoding="utf-8") as f: + + # Build template + content = TEMPLATES[type_name].format(name=snake_name, camel_name=camel_name) + with open(file_path, "w", encoding="utf-8") as f: f.write(content) - print(f"Generated module boilerplate in '{filename}'") + + print(f"\033[32m✓\033[0m Created {type_name}: {os.path.relpath(file_path, base_path)}") + + # If generating module, generate related tools, resources, prompts unless skip-related is set + if type_name == "module" and not getattr(options, "skip_related", False): + for sub_type in ("tools", "resources", "prompts"): + sub_filename = f"{snake_name}_{sub_type}.py" + sub_file_path = os.path.join(dir_path, sub_filename) + if os.path.exists(sub_file_path) and not getattr(options, "force", False): + continue + + sub_content = TEMPLATES[sub_type].format(name=snake_name, camel_name=camel_name) + with open(sub_file_path, "w", encoding="utf-8") as f: + f.write(sub_content) + print(f"\033[32m✓\033[0m Created {sub_type}: {os.path.relpath(sub_file_path, base_path)}") def get_claude_config_paths(): paths = [] @@ -1238,14 +1730,35 @@ def main(): # init command init_parser = subparsers.add_parser("init", help="Initialize a new NitroStack MCP server project") - init_parser.add_argument("name", help="Name of the project directory to create") + init_parser.add_argument("name", nargs="?", default=None, help="Name of the project directory to create") init_parser.add_argument("--template", choices=["calculator", "food-delivery", "flight-booking", "starter", "pizzaz", "oauth"], default=None, help="Template to use (default: interactive prompt)") + init_parser.add_argument("--description", default=None, help="Description of the project") + init_parser.add_argument("--author", default=None, help="Author of the project") + init_parser.add_argument("--skip-install", action="store_true", help="Skip installing dependencies") # dev command - subparsers.add_parser("dev", help="Start the hot-reloading development server") + dev_parser = subparsers.add_parser("dev", help="Start the hot-reloading development server") + dev_parser.add_argument("--file", default="main.py", help="Python script to register (defaults to main.py)") + dev_parser.add_argument("--port", type=int, default=3001, help="Port for widget dev server (default: 3001)") # start command - subparsers.add_parser("start", help="Start the production server") + start_parser = subparsers.add_parser("start", help="Start the production server") + start_parser.add_argument("--file", default="main.py", help="Python script to register (defaults to main.py)") + start_parser.add_argument("--port", type=int, default=None, help="Port for server") + + # install command + install_parser = subparsers.add_parser("install", aliases=["i"], help="Install dependencies in root and src/widgets directories") + install_parser.add_argument("--skip-widgets", action="store_true", help="Skip installing widget dependencies") + install_parser.add_argument("--production", action="store_true", help="Install production dependencies only") + + # build command + build_parser = subparsers.add_parser("build", help="Build the project for production") + build_parser.add_argument("--output", default="dist", help="Output directory") + + # upgrade command + upgrade_parser = subparsers.add_parser("upgrade", help="Upgrade nitrostack to the latest version in the project") + upgrade_parser.add_argument("--dry-run", action="store_true", help="Show what would be upgraded without making changes") + upgrade_parser.add_argument("--latest", action="store_true", help="Force upgrade to the latest version even if already up to date") # register command reg_parser = subparsers.add_parser("register", help="Register server script inside Claude Desktop configuration") @@ -1253,14 +1766,22 @@ def main(): reg_parser.add_argument("--file", default="main.py", help="Python script to register (defaults to main.py)") # generate command - gen_parser = subparsers.add_parser("generate", help="Generate boilerplate code") + gen_parser = subparsers.add_parser("generate", aliases=["g"], help="Generate boilerplate code") + gen_parser.add_argument("--module", help="Module name (for module-specific generation)") + gen_parser.add_argument("--output", help="Output path") + gen_parser.add_argument("--force", action="store_true", help="Overwrite existing files") + gen_parser.add_argument("--skip-related", action="store_true", help="Skip generating related files (for modules)") gen_subparsers = gen_parser.add_subparsers(dest="generator") - tool_parser = gen_subparsers.add_parser("tool", help="Generate a new tool boilerplate") - tool_parser.add_argument("name", help="Name of the tool") - - mod_parser = gen_subparsers.add_parser("module", help="Generate a new module boilerplate") - mod_parser.add_argument("name", help="Name of the module") + # types subparser (without name argument) + gen_subparsers.add_parser("types", help="Generate TypeScript definitions from tool definitions") + + # Generator subcommands (all except types require name) + for gen_type in list(TEMPLATES.keys()) + ["tool"]: + if gen_type == "types": + continue + gen_type_parser = gen_subparsers.add_parser(gen_type, help=f"Generate a new {gen_type} boilerplate") + gen_type_parser.add_argument("name", help=f"Name of the {gen_type}") args = parser.parse_args() @@ -1269,21 +1790,25 @@ def main(): sys.exit(1) if args.command == "init": - init_project(args.name, args.template) + init_project(args.name, args.template, args.description, args.author, args.skip_install) elif args.command == "dev": - run_dev() + run_dev(args.file, args.port) elif args.command == "start": - run_start() + run_start(args.file, args.port) + elif args.command == "install": + run_install(args.skip_widgets, args.production) + elif args.command == "build": + run_build(args.output) + elif args.command == "upgrade": + run_upgrade(args.dry_run, args.latest) elif args.command == "register": register_server(args.name, args.file) elif args.command == "generate": if not args.generator: parser.parse_args(["generate", "--help"]) sys.exit(1) - if args.generator == "tool": - generate_tool(args.name) - elif args.generator == "module": - generate_module(args.name) + name = getattr(args, "name", None) + generate_component(args.generator, name, args) if __name__ == "__main__": main() diff --git a/nitrostack/core/__pycache__/additional_decorators.cpython-312.pyc b/nitrostack/core/__pycache__/additional_decorators.cpython-312.pyc deleted file mode 100644 index 7c978a4..0000000 Binary files a/nitrostack/core/__pycache__/additional_decorators.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/app.cpython-312.pyc b/nitrostack/core/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 2f7c654..0000000 Binary files a/nitrostack/core/__pycache__/app.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/context.cpython-312.pyc b/nitrostack/core/__pycache__/context.cpython-312.pyc deleted file mode 100644 index 6731ee2..0000000 Binary files a/nitrostack/core/__pycache__/context.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/decorators.cpython-312.pyc b/nitrostack/core/__pycache__/decorators.cpython-312.pyc deleted file mode 100644 index a92614e..0000000 Binary files a/nitrostack/core/__pycache__/decorators.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/di.cpython-312.pyc b/nitrostack/core/__pycache__/di.cpython-312.pyc deleted file mode 100644 index 0d33e9b..0000000 Binary files a/nitrostack/core/__pycache__/di.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/errors.cpython-312.pyc b/nitrostack/core/__pycache__/errors.cpython-312.pyc deleted file mode 100644 index 734f478..0000000 Binary files a/nitrostack/core/__pycache__/errors.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/module.cpython-312.pyc b/nitrostack/core/__pycache__/module.cpython-312.pyc deleted file mode 100644 index 63d733e..0000000 Binary files a/nitrostack/core/__pycache__/module.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/pipeline.cpython-312.pyc b/nitrostack/core/__pycache__/pipeline.cpython-312.pyc deleted file mode 100644 index 879e925..0000000 Binary files a/nitrostack/core/__pycache__/pipeline.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/__pycache__/task.cpython-312.pyc b/nitrostack/core/__pycache__/task.cpython-312.pyc deleted file mode 100644 index d3cec68..0000000 Binary files a/nitrostack/core/__pycache__/task.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/core/additional_decorators.py b/nitrostack/core/additional_decorators.py index 14df053..15978af 100644 --- a/nitrostack/core/additional_decorators.py +++ b/nitrostack/core/additional_decorators.py @@ -1,6 +1,9 @@ import time +import re +import json +import threading from functools import wraps -from typing import Callable, Any, Dict, List, Tuple +from typing import Callable, Any, Dict, List, Tuple, Union, Optional from nitrostack.core.context import ExecutionContext def copy_mcp_attributes(src: Any, dst: Any) -> None: @@ -9,23 +12,94 @@ def copy_mcp_attributes(src: Any, dst: Any) -> None: if attr.startswith("_mcp_"): setattr(dst, attr, getattr(src, attr)) -def cache(ttl: int = 60): +class InMemoryCacheStorage: + def __init__(self): + self._cache = {} + self._lock = threading.Lock() + + def get(self, key: str) -> Any: + with self._lock: + item = self._cache.get(key) + if not item: + return None + if item["expires"] < time.time(): + self._cache.pop(key, None) + return None + return item["value"] + + def set(self, key: str, value: Any, ttl: int) -> None: + with self._lock: + self._cache[key] = { + "value": value, + "expires": time.time() + ttl + } + + def delete(self, key: str) -> None: + with self._lock: + self._cache.pop(key, None) + + def clear(self) -> None: + with self._lock: + self._cache.clear() + + def cleanup(self) -> None: + with self._lock: + now = time.time() + for key, item in list(self._cache.items()): + if item["expires"] < now: + self._cache.pop(key, None) + +default_cache_storage = InMemoryCacheStorage() + +def _start_cache_cleanup(): + def cleanup_loop(): + while True: + time.sleep(60) + try: + default_cache_storage.cleanup() + except Exception: + pass + t = threading.Thread(target=cleanup_loop, daemon=True) + t.start() + +_start_cache_cleanup() + +def cache( + ttl: int = 60, + key: Optional[Callable[[Any, Any], str]] = None, + storage: Any = None +): """ Caches method outputs for a specific TTL (in seconds). - Skips ExecutionContext parameters when generating the cache key. + Constructs a cache key by serializing inputs, excluding the ExecutionContext. """ - def decorator(func: Callable): - cache_store: Dict[Tuple[Any, ...], Tuple[Any, float]] = {} + active_storage = storage or default_cache_storage + def decorator(func: Callable): @wraps(func) async def wrapper(*args, **kwargs): - # Construct a cache key by filtering out ExecutionContext and self + # Find execution context + context = None + for arg in args: + if isinstance(arg, ExecutionContext) or type(arg).__name__ == "ExecutionContext": + context = arg + break + if not context: + for v in kwargs.values(): + if isinstance(v, ExecutionContext) or type(v).__name__ == "ExecutionContext": + context = v + break + + # Filter args and kwargs for the cache key filtered_args = [] for arg in args: - # Skip self if it has class attributes or is the instance, skip ExecutionContext if isinstance(arg, ExecutionContext) or type(arg).__name__ == "ExecutionContext": continue - filtered_args.append(arg) + # If arg is 'self', use its class name + if hasattr(arg, "__class__") and not isinstance(arg, (int, float, str, bool, list, dict, set, tuple)) and arg.__class__.__name__ not in ("int", "float", "str", "bool", "list", "dict", "set", "tuple"): + filtered_args.append(arg.__class__.__name__) + else: + filtered_args.append(arg) filtered_kwargs = {} for k, v in kwargs.items(): @@ -33,40 +107,170 @@ async def wrapper(*args, **kwargs): continue filtered_kwargs[k] = v - # Standardize key (args and kwargs) - key = (tuple(filtered_args), frozenset(filtered_kwargs.items())) + # Generate cache key + if key: + input_val = args[0] if len(args) > 0 else None + # Strip self if present + if len(args) > 1 and hasattr(args[0], "__class__") and args[0].__class__.__name__ not in ("int", "float", "str", "bool", "list", "dict", "set", "tuple"): + input_val = args[1] + cache_key = key(input_val, context) + else: + serialized_args = [] + for a in filtered_args: + if isinstance(a, dict) and "_meta" in a: + a_copy = a.copy() + a_copy.pop("_meta") + serialized_args.append(a_copy) + else: + serialized_args.append(a) + + serialized_kwargs = {} + for k, v in filtered_kwargs.items(): + if k == "_meta": + continue + serialized_kwargs[k] = v + + try: + key_parts = { + "func": f"{func.__module__}.{func.__name__}", + "args": serialized_args, + "kwargs": serialized_kwargs + } + cache_key = json.dumps(key_parts, sort_keys=True) + except Exception: + cache_key = f"{func.__module__}.{func.__name__}:{str(filtered_args)}:{str(filtered_kwargs)}" - now = time.time() - if key in cache_store: - val, expiry = cache_store[key] - if now < expiry: - return val + import sys + print(f"[Cache DEBUG] Checking cache for key: {cache_key}", file=sys.stderr) + + # Check cache + cached_val = active_storage.get(cache_key) + if cached_val is not None: + print(f"[Cache DEBUG] HIT - returning cached result", file=sys.stderr) + if context and getattr(context, "logger", None): + context.logger.info(f"[Cache] Hit for key: {cache_key}") + return cached_val + print(f"[Cache DEBUG] MISS - executing method", file=sys.stderr) result = await func(*args, **kwargs) - cache_store[key] = (result, now + ttl) + + # Store in cache + active_storage.set(cache_key, result, ttl) + print(f"[Cache DEBUG] Stored result for {ttl}s", file=sys.stderr) + if context and getattr(context, "logger", None): + context.logger.info(f"[Cache] Miss for key: {cache_key}, stored for {ttl}s") + return result copy_mcp_attributes(func, wrapper) return wrapper return decorator -def rate_limit(max: int, window: int): +def parse_window(window: Union[int, str]) -> int: + """Parse time window string (e.g. '1m', '10s', '2h') to seconds.""" + if isinstance(window, int): + return window + match = re.match(r"^(\d+)([smhd])$", window) + if not match: + raise ValueError(f"Invalid time window format: {window}. Use format like '1m', '1h', '1d'") + value = int(match[1]) + unit = match[2] + multipliers = { + "s": 1, + "m": 60, + "h": 3600, + "d": 86400, + } + return value * multipliers[unit] + +class InMemoryRateLimitStorage: + def __init__(self): + self._limits = {} + self._lock = threading.Lock() + + def increment(self, key: str, window_seconds: int) -> int: + with self._lock: + now = time.time() + limit = self._limits.get(key) + if not limit or limit["reset_at"] < now: + self._limits[key] = { + "count": 1, + "reset_at": now + window_seconds + } + return 1 + limit["count"] += 1 + return limit["count"] + + def reset(self, key: str) -> None: + with self._lock: + self._limits.pop(key, None) + + def cleanup(self) -> None: + with self._lock: + now = time.time() + for key, limit in list(self._limits.items()): + if limit["reset_at"] < now: + self._limits.pop(key, None) + +default_rate_limit_storage = InMemoryRateLimitStorage() + +def _start_rate_limit_cleanup(): + def cleanup_loop(): + while True: + time.sleep(60) + try: + default_rate_limit_storage.cleanup() + except Exception: + pass + t = threading.Thread(target=cleanup_loop, daemon=True) + t.start() + +_start_rate_limit_cleanup() + +def rate_limit( + max: int, + window: Union[int, str], + key: Optional[Callable[[ExecutionContext], str]] = None, + storage: Any = None, + message: Optional[str] = None +): """ - Rate limits method calls to max calls per window (in seconds). + Rate limits method calls to max calls per window. """ - def decorator(func: Callable): - calls: List[float] = [] + window_secs = parse_window(window) + active_storage = storage or default_rate_limit_storage + def decorator(func: Callable): @wraps(func) async def wrapper(*args, **kwargs): - nonlocal calls - now = time.time() - # Filter timestamps in the window - calls = [t for t in calls if now - t < window] - if len(calls) >= max: - raise ValueError(f"Rate limit exceeded. Maximum of {max} calls allowed every {window} seconds.") - - calls.append(now) + context = None + for arg in args: + if isinstance(arg, ExecutionContext) or type(arg).__name__ == "ExecutionContext": + context = arg + break + if not context: + for v in kwargs.values(): + if isinstance(v, ExecutionContext) or type(v).__name__ == "ExecutionContext": + context = v + break + + if key and context: + rate_key = key(context) + else: + rate_key = getattr(getattr(context, "auth", None), "subject", None) or "anonymous" + + full_key = f"{func.__module__}.{func.__name__}:{rate_key}" + count = active_storage.increment(full_key, window_secs) + + if count > max: + err_msg = message or f"Rate limit exceeded. Maximum {max} requests per {window}" + if context and getattr(context, "logger", None): + context.logger.warn(f"[RateLimit] Limit exceeded for key: {full_key}") + raise ValueError(err_msg) + + if context and getattr(context, "logger", None): + context.logger.info(f"[RateLimit] Request {count}/{max} for key: {full_key}") + return await func(*args, **kwargs) copy_mcp_attributes(func, wrapper) @@ -86,7 +290,6 @@ def register(cls, name: str, func: Callable, class_type: Any = None) -> None: @classmethod def bind_instance(cls, name: str, func: Callable, instance: Any) -> None: - # Bind the method to the resolved instance import inspect if inspect.ismethod(func): cls._bound_checks[name] = func @@ -102,10 +305,8 @@ def run_all(cls) -> Dict[str, str]: results = {} for name, check_fn in cls._bound_checks.items(): try: - # Handle both sync and async checks import inspect if inspect.iscoroutinefunction(check_fn): - # In a real environment, we'd await it, but let's handle it or run via loop import asyncio try: loop = asyncio.get_event_loop() @@ -126,8 +327,6 @@ def health_check(name: str): """ def decorator(func: Callable): func._mcp_health_check_name = name - # We don't have the class type here yet, but we will register it. - # It will be bound during discovery in the McpApplicationFactory. HealthCheckRegistry.register(name, func) return func return decorator diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index b694ab7..b3ef22a 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -18,18 +18,63 @@ # Monkeypatch FastMCP.call_tool to support returning CreateTaskResult without conversion _original_fastmcp_call_tool = FastMCP.call_tool +def handle_tool_error(e: Exception) -> Any: + import mcp.types as types + import pydantic + from nitrostack.core.errors import ValidationError + + # 1. Pydantic v2 ValidationError + if isinstance(e, pydantic.ValidationError): + errors_details = [] + for err in e.errors(): + loc = " -> ".join(str(l) for l in err.get("loc", [])) + msg = err.get("msg", "Unknown error") + errors_details.append(f"Field '{loc}': {msg}") + err_msg = "Validation failed:\n" + "\n".join(errors_details) + return types.CallToolResult( + content=[types.TextContent(type="text", text=err_msg)], + isError=True + ) + + # 2. SDK custom ValidationError + if isinstance(e, ValidationError): + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Validation Error: {str(e)}")], + isError=True + ) + + # 3. PermissionError / Guard auth failures + if isinstance(e, PermissionError) or "Access denied" in str(e): + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Access Denied: {str(e)}")], + isError=True + ) + + # 4. Fallback for other exceptions + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Error: {str(e)}")], + isError=True + ) + + async def _custom_fastmcp_call_tool(self, name: str, arguments: dict[str, Any]): t = self._tool_manager.get_tool(name) if not t: return await _original_fastmcp_call_tool(self, name, arguments) context = self.get_context() - result = await self._tool_manager.call_tool( - name, arguments, context=context, convert_result=False - ) - import mcp.types as types - if isinstance(result, types.CreateTaskResult): - return result - return t.fn_metadata.convert_result(result) + try: + result = await self._tool_manager.call_tool( + name, arguments, context=context, convert_result=False + ) + import mcp.types as types + if isinstance(result, types.CreateTaskResult): + return result + return t.fn_metadata.convert_result(result) + except Exception as e: + orig_err = e + if hasattr(e, "__cause__") and e.__cause__: + orig_err = e.__cause__ + return handle_tool_error(orig_err) FastMCP.call_tool = _custom_fastmcp_call_tool @@ -82,8 +127,6 @@ def get_pydantic_model(schema: Any) -> Type[BaseModel]: # Return a default empty model if invalid or empty return create_model("EmptyInputModel") - - class McpApplication: def __init__(self, app_class: Type): self.app_class = app_class @@ -161,6 +204,17 @@ def health_status_resource() -> str: results = HealthCheckRegistry.run_all() return json.dumps(results) + # 4b. Register Built-in Widget Examples Resource if widget-manifest.json exists + widget_manifest_path = os.path.join(os.getcwd(), "src", "widgets", "widget-manifest.json") + if os.path.exists(widget_manifest_path): + @self.mcp_server.resource("widget://examples", name="Widget Examples", description="Provides metadata and examples for all registered UI widgets", mime_type="application/json") + def widget_examples_resource() -> str: + try: + with open(widget_manifest_path, "r", encoding="utf-8") as f: + return f.read() + except Exception as e: + return f'{{"error": "Failed to read manifest: {str(e)}"}}' + # 5. Register Task Support hook & endpoints low_level_server = self.mcp_server._mcp_server original_get_caps = low_level_server.get_capabilities @@ -258,6 +312,8 @@ async def handle_get_task_payload(req): isError=True ) else: + if isinstance(t.error, types.CallToolResult): + return t.error return types.CallToolResult( content=[types.TextContent(type="text", text=str(t.error or t.status_message))], isError=True @@ -412,6 +468,11 @@ async def background_execution(): elif isinstance(result_dump, dict): if "content" in result_dump and "isError" in result_dump: final_result = types.CallToolResult(**result_dump) + elif result_dump.get("error") is True: + final_result = types.CallToolResult( + content=[types.TextContent(type="text", text=str(result_dump.get("message") or result_dump.get("error")))], + isError=True + ) else: final_result = types.CallToolResult( content=[types.TextContent(type="text", text=json.dumps(result_dump, indent=2))], @@ -425,7 +486,8 @@ async def background_execution(): ) TaskRegistry.complete_task(task_id, final_result) except Exception as e: - TaskRegistry.fail_task(task_id, e) + translated_error = handle_tool_error(e) + TaskRegistry.fail_task(task_id, translated_error) import asyncio asyncio.create_task(background_execution()) @@ -459,24 +521,45 @@ async def background_execution(): filters = getattr(method, "_mcp_filters", []) # Run through pipeline runner - result = await run_pipeline( - handler=method, - handler_instance=instance, - args=(input, ctx), - kwargs={}, - context=ctx, - guards=guards, - middleware=middleware, - interceptors=interceptors, - pipes=pipes, - filters=filters, - param_name="input", - param_type=input_model - ) + try: + result = await run_pipeline( + handler=method, + handler_instance=instance, + args=(input, ctx), + kwargs={}, + context=ctx, + guards=guards, + middleware=middleware, + interceptors=interceptors, + pipes=pipes, + filters=filters, + param_name="input", + param_type=input_model + ) - if isinstance(result, BaseModel): - return result.model_dump() - return result + if isinstance(result, BaseModel): + result_dump = result.model_dump() + else: + result_dump = result + + import mcp.types as types + if isinstance(result_dump, types.CallToolResult): + return result_dump + elif isinstance(result_dump, dict): + if "content" in result_dump and "isError" in result_dump: + return types.CallToolResult(**result_dump) + elif result_dump.get("error") is True: + err_msg = result_dump.get("message") or result_dump.get("error") + return types.CallToolResult( + content=[types.TextContent(type="text", text=str(err_msg))], + isError=True + ) + else: + return result_dump + else: + return result_dump + except Exception as e: + return handle_tool_error(e) # Register metadata meta = { diff --git a/nitrostack/core/decorators.py b/nitrostack/core/decorators.py index debdc84..c1400d7 100644 --- a/nitrostack/core/decorators.py +++ b/nitrostack/core/decorators.py @@ -71,8 +71,8 @@ class PromptConfig: def tool( name: str, - description: str, - input_schema: Any, + description: Optional[str] = None, + input_schema: Optional[Any] = None, title: Optional[str] = None, output_schema: Optional[Any] = None, annotations: Optional[ToolAnnotations] = None, @@ -91,11 +91,17 @@ def tool( metadata = {} def decorator(func: Callable): + desc = description + if not desc and func.__doc__: + desc = func.__doc__.strip() + if not desc: + desc = "" + # Attach or update tool config config = ToolConfig( name=name, title=title, - description=description, + description=desc, input_schema=input_schema, output_schema=output_schema, annotations=annotations, @@ -143,7 +149,7 @@ def initial_tool(func: Callable): def resource( uri: str, name: str, - description: str, + description: Optional[str] = None, title: Optional[str] = None, mime_type: Optional[str] = None, size: Optional[int] = None, @@ -159,10 +165,16 @@ def resource( metadata = {} def decorator(func: Callable): + desc = description + if not desc and func.__doc__: + desc = func.__doc__.strip() + if not desc: + desc = "" + config = ResourceConfig( uri=uri, name=name, - description=description, + description=desc, title=title, mime_type=mime_type, size=size, @@ -175,7 +187,7 @@ def decorator(func: Callable): def prompt( name: str, - description: str, + description: Optional[str] = None, arguments: Optional[List[PromptArgument]] = None, ): """ @@ -185,9 +197,15 @@ def prompt( arguments = [] def decorator(func: Callable): + desc = description + if not desc and func.__doc__: + desc = func.__doc__.strip() + if not desc: + desc = "" + config = PromptConfig( name=name, - description=description, + description=desc, arguments=arguments ) func._mcp_prompt_config = config diff --git a/nitrostack/core/di.py b/nitrostack/core/di.py index 2e086b1..7f484dc 100644 --- a/nitrostack/core/di.py +++ b/nitrostack/core/di.py @@ -1,83 +1,162 @@ -from typing import Any, Dict, List, Type, Union +import inspect +import threading +import typing +from typing import Any, Dict, List, Type, Union, Optional + +def _get_class_dependencies(cls: Type) -> List[Any]: + """Helper to inspect constructor signature and retrieve type-annotated dependencies.""" + if not hasattr(cls, "__init__") or cls.__init__ is object.__init__: + return [] + + # Get signature and type hints of the __init__ method + sig = inspect.signature(cls.__init__) + try: + # Resolve any forward references/strings in annotations relative to the class's module + type_hints = typing.get_type_hints(cls.__init__) + except Exception: + type_hints = {} + + deps = [] + for param_name, param in sig.parameters.items(): + if param_name == "self": + continue + # Skip *args and **kwargs + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + + # Use typing.get_type_hints if available, fallback to parameter.annotation + dep_type = type_hints.get(param_name, param.annotation) + if dep_type is inspect.Parameter.empty: + # If a dependency is missing annotations and has no default value, we cannot resolve it. + if param.default is inspect.Parameter.empty: + from nitrostack.core.errors import DependencyResolutionError + raise DependencyResolutionError( + f"Parameter '{param_name}' of class '{cls.__name__}' is missing type annotations and cannot be auto-resolved." + ) + else: + continue + + # If the parameter has a default value, we only resolve it if it is registered in the DIContainer. + # Otherwise, we skip it and let Python use the default value. + if param.default is not inspect.Parameter.empty: + container = DIContainer.get_instance() + if dep_type not in container._registry and dep_type not in container._instances: + continue + + deps.append(dep_type) + + return deps class DIContainer: _instance = None + _instance_lock = threading.Lock() @classmethod def get_instance(cls) -> "DIContainer": if cls._instance is None: - cls._instance = cls() + with cls._instance_lock: + if cls._instance is None: + cls._instance = cls() return cls._instance @classmethod def reset(cls) -> None: """Reset the singleton container (useful for testing).""" - cls._instance = None + with cls._instance_lock: + cls._instance = None def __init__(self): self._registry: Dict[Any, Type] = {} self._instances: Dict[Any, Any] = {} + self._lock = threading.RLock() - def register(self, cls: Type) -> None: + def register(self, cls: Type, token: Optional[Any] = None) -> None: """Register a provider class.""" - self._registry[cls] = cls + with self._lock: + reg_token = token if token is not None else cls + self._registry[reg_token] = cls + if token is not None: + self._registry[cls] = cls def register_value(self, token: Any, value: Any) -> None: """Register a constant value or instantiated service with a token.""" - self._instances[token] = value - # Also map token to its type if possible - if not isinstance(token, str): - self._registry[token] = type(value) + with self._lock: + self._instances[token] = value + # Also map token to its type if possible + if not isinstance(token, str): + self._registry[token] = type(value) - def resolve(self, token: Any) -> Any: + def resolve(self, token: Any, resolving: Optional[List[Any]] = None) -> Any: """ Resolve a dependency by token (class type or string key). Instantiates classes if not already instantiated. """ - # 1. Check if we already have a cached instance - if token in self._instances: - return self._instances[token] - - # 2. Check if the token is registered as a class - cls = self._registry.get(token) - - # 3. If not registered, but it's a class type, check if it's decorated with @injectable - if cls is None and isinstance(token, type): - cls = token - # We auto-register it to make usage easier - self.register(cls) - - from nitrostack.core.errors import DependencyResolutionError - - if cls is None: - raise DependencyResolutionError(f"Dependency '{token}' is not registered in the DIContainer.") - - # 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 - try: - instance = cls(*resolved_args) - except Exception as e: - raise DependencyResolutionError(f"Failed to instantiate class '{cls.__name__}' due to: {e}") from e - - # 6. Cache and return the singleton instance - self._instances[token] = instance - return instance - -def injectable(deps: List[Any] = None): + if resolving is None: + resolving = [] + + with self._lock: + # 1. Check if we already have a cached instance + if token in self._instances: + return self._instances[token] + + # 2. Circular dependency detection + if token in resolving: + from nitrostack.core.errors import CircularDependencyError + path = " -> ".join(str(t) for t in resolving + [token]) + raise CircularDependencyError(f"Circular dependency detected: {path}") + + # 3. Check if the token is registered as a class + cls = self._registry.get(token) + + # 4. If not registered, but it's a class type, check if it's decorated with @injectable + if cls is None and isinstance(token, type): + cls = token + # We auto-register it to make usage easier + self.register(cls) + + from nitrostack.core.errors import DependencyResolutionError + + if cls is None: + raise DependencyResolutionError(f"Dependency '{token}' is not registered in the DIContainer.") + + # 5. Resolve dependencies of the class + deps = getattr(cls, "_mcp_deps", None) + if deps is None: + deps = _get_class_dependencies(cls) + + resolved_args = [] + next_resolving = resolving + [token] + for dep in deps: + resolved_args.append(self.resolve(dep, next_resolving)) + + # 6. Instantiate the class + try: + instance = cls(*resolved_args) + except Exception as e: + from nitrostack.core.errors import DIError + if isinstance(e, DIError): + raise + raise DependencyResolutionError(f"Failed to instantiate class '{cls.__name__}' due to: {e}") from e + + # 7. Cache and return the singleton instance + self._instances[token] = instance + self._instances[cls] = instance + + provide = getattr(cls, "_mcp_provide", None) + if provide is not None: + self._instances[provide] = instance + + return instance + +def injectable(deps: Optional[List[Any]] = None, provide: Optional[Any] = None): """ Decorator to mark a class as Injectable. - Requires explicit list of dependencies. + Allows custom token registration and automatic/explicit dependency resolution. """ - if deps is None: - deps = [] def decorator(cls: Type): cls._mcp_deps = deps + cls._mcp_provide = provide # Automatically register with the DIContainer - DIContainer.get_instance().register(cls) + DIContainer.get_instance().register(cls, token=provide) return cls return decorator diff --git a/nitrostack/core/errors.py b/nitrostack/core/errors.py index a95f4aa..dbbf0e6 100644 --- a/nitrostack/core/errors.py +++ b/nitrostack/core/errors.py @@ -22,6 +22,10 @@ class DependencyResolutionError(DIError): """Raised when a dependency cannot be resolved by the DI container.""" pass +class CircularDependencyError(DIError): + """Raised when a circular dependency is detected.""" + pass + class ConfigurationError(Exception): """Raised when configuration validation fails.""" pass diff --git a/nitrostack/core/pipeline.py b/nitrostack/core/pipeline.py index f529910..e64fc68 100644 --- a/nitrostack/core/pipeline.py +++ b/nitrostack/core/pipeline.py @@ -197,14 +197,25 @@ async def execute_handler_flow(): current_args[0] = val # 3. Chain Middleware and Interceptors - async def call_target(): + async def call_target(ctx): + target_args = list(current_args) + target_kwargs = dict(kwargs) + + for idx, arg in enumerate(target_args): + if isinstance(arg, ExecutionContext) or type(arg).__name__ == "ExecutionContext": + target_args[idx] = ctx + + for k, v in target_kwargs.items(): + if isinstance(v, ExecutionContext) or type(v).__name__ == "ExecutionContext": + target_kwargs[k] = ctx + import inspect if inspect.ismethod(handler): - return await handler(*current_args, **kwargs) + return await handler(*target_args, **target_kwargs) elif handler_instance is not None: - return await handler(handler_instance, *current_args, **kwargs) + return await handler(handler_instance, *target_args, **target_kwargs) else: - return await handler(*current_args, **kwargs) + return await handler(*target_args, **target_kwargs) # We construct the next chain backwards: # Middleware -> Interceptors -> Handler @@ -212,17 +223,27 @@ async def call_target(): for interceptor_cls in reversed(interceptors): interceptor = container.resolve(interceptor_cls) def make_interceptor_next(nxt): - return lambda: interceptor.intercept(context, nxt) + async def step(ctx): + async def call_next(*a, **k): + passed_ctx = a[0] if a else ctx + return await nxt(passed_ctx) + return await interceptor.intercept(ctx, call_next) + return step 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) + async def step(ctx): + async def call_next(*a, **k): + passed_ctx = a[0] if a else ctx + return await nxt(passed_ctx) + return await mw.use(ctx, call_next) + return step current_next = make_middleware_next(current_next) # Execute the chain - return await current_next() + return await current_next(context) # Wrap the entire flow in Exception Filters try: diff --git a/nitrostack/events/__pycache__/event_emitter.cpython-312.pyc b/nitrostack/events/__pycache__/event_emitter.cpython-312.pyc deleted file mode 100644 index 7d8820e..0000000 Binary files a/nitrostack/events/__pycache__/event_emitter.cpython-312.pyc and /dev/null differ diff --git a/nitrostack/testing/__pycache__/__init__.cpython-312.pyc b/nitrostack/testing/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index b3e6d2d..0000000 Binary files a/nitrostack/testing/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/tests/__pycache__/test_basic.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_basic.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index 51d1b21..0000000 Binary files a/tests/__pycache__/test_basic.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_initial_tool.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_initial_tool.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index cd8dfb8..0000000 Binary files a/tests/__pycache__/test_initial_tool.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_oauth.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_oauth.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index a575ff8..0000000 Binary files a/tests/__pycache__/test_oauth.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_production.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_production.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index bb3738a..0000000 Binary files a/tests/__pycache__/test_production.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index 505d942..0000000 Binary files a/tests/__pycache__/test_tasks.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/__pycache__/test_widget_metadata.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_widget_metadata.cpython-312-pytest-9.0.2.pyc deleted file mode 100644 index e1f1730..0000000 Binary files a/tests/__pycache__/test_widget_metadata.cpython-312-pytest-9.0.2.pyc and /dev/null differ diff --git a/tests/test_decorators.py b/tests/test_decorators.py new file mode 100644 index 0000000..ba3afb2 --- /dev/null +++ b/tests/test_decorators.py @@ -0,0 +1,185 @@ +import asyncio +import os +import sys +import time +from pydantic import BaseModel + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack import ExecutionContext, AuthContext +from nitrostack.core.additional_decorators import cache, rate_limit + +class MockLogger: + def __init__(self): + self.infos = [] + self.warns = [] + + def info(self, msg): + self.infos.append(msg) + + def warn(self, msg): + self.warns.append(msg) + +async def _test_cache_decorator(): + call_count = 0 + + @cache(ttl=1) + async def get_value(x: int, context: ExecutionContext): + nonlocal call_count + call_count += 1 + return x * 2 + + ctx = ExecutionContext( + request_id="test-req-cache", + tool_name="test_tool", + metadata={}, + logger=MockLogger() + ) + + # 1. First call - miss + res1 = await get_value(10, ctx) + assert res1 == 20 + assert call_count == 1 + + # 2. Second call - hit + res2 = await get_value(10, ctx) + assert res2 == 20 + assert call_count == 1 # count should not increment + + # 3. Call with different parameter - miss + res3 = await get_value(20, ctx) + assert res3 == 40 + assert call_count == 2 + + # 4. Wait for TTL expiration + await asyncio.sleep(1.1) + res4 = await get_value(10, ctx) + assert res4 == 20 + assert call_count == 3 # count should increment now + +def test_cache_decorator(): + asyncio.run(_test_cache_decorator()) + +async def _test_rate_limit_decorator(): + call_count = 0 + + @rate_limit(max=2, window=1) + async def limited_function(context: ExecutionContext): + nonlocal call_count + call_count += 1 + return "ok" + + ctx_anon = ExecutionContext( + request_id="test-req-rl1", + tool_name="test_tool", + metadata={}, + logger=MockLogger(), + auth=AuthContext(subject="anon-user") + ) + + ctx_other = ExecutionContext( + request_id="test-req-rl2", + tool_name="test_tool", + metadata={}, + logger=MockLogger(), + auth=AuthContext(subject="other-user") + ) + + # User 'anon-user' calling twice: should pass + assert await limited_function(ctx_anon) == "ok" + assert await limited_function(ctx_anon) == "ok" + + # User 'anon-user' calling third time: should raise ValueError + try: + await limited_function(ctx_anon) + assert False, "Should have rate limited" + except ValueError as exc: + assert "rate limit exceeded" in str(exc).lower() + + # User 'other-user' calling: should pass (partitioned limits) + assert await limited_function(ctx_other) == "ok" + +def test_rate_limit_decorator(): + asyncio.run(_test_rate_limit_decorator()) + +async def _test_widget_examples_resource(): + from nitrostack.core.app import McpApplication, ServerConfig + from nitrostack import module + import tempfile + import shutil + import json + + # Create temp directory layout matching src/widgets/widget-manifest.json + temp_dir = tempfile.mkdtemp() + widgets_dir = os.path.join(temp_dir, "src", "widgets") + os.makedirs(widgets_dir) + + manifest_data = { + "version": "1.0.0", + "widgets": [ + {"uri": "widget://test", "name": "TestWidget", "description": "Desc", "examples": []} + ], + "generatedAt": "2026-07-07" + } + + manifest_path = os.path.join(widgets_dir, "widget-manifest.json") + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest_data, f) + + old_cwd = os.getcwd() + os.chdir(temp_dir) + try: + @module(name="test_widget") + class TestWidgetModule: + pass + app = McpApplication(TestWidgetModule) + import mcp.types as types + assert "widget://examples" in app.mcp_server._resource_manager._resources + + handler = app.mcp_server._mcp_server.request_handlers[types.ReadResourceRequest] + req = types.ReadResourceRequest( + method="resources/read", + params=types.ReadResourceRequestParams(uri="widget://examples") + ) + res = await handler(req) + data = json.loads(res.root.contents[0].text) + assert data["version"] == "1.0.0" + assert data["widgets"][0]["name"] == "TestWidget" + finally: + os.chdir(old_cwd) + shutil.rmtree(temp_dir) + +def test_widget_examples_resource(): + asyncio.run(_test_widget_examples_resource()) + +async def _test_docstring_fallback(): + from nitrostack.core.decorators import tool, prompt, resource + + class DummyInput(BaseModel): + val: int + + @tool(name="doc_tool", input_schema=DummyInput) + def my_tool_func(input: DummyInput, ctx: ExecutionContext): + """This is my tool docstring description. + It spans multiple lines. + """ + pass + + assert my_tool_func._mcp_tool_config.description == "This is my tool docstring description.\n It spans multiple lines." + + @prompt(name="doc_prompt") + def my_prompt_func(args: dict, ctx: ExecutionContext): + """This is my prompt docstring.""" + pass + + assert my_prompt_func._mcp_prompt_config.description == "This is my prompt docstring." + + @resource(uri="doc://resource", name="doc_res") + def my_resource_func(ctx: ExecutionContext): + """This is my resource docstring.""" + pass + + assert my_resource_func._mcp_resource_config.description == "This is my resource docstring." + +def test_docstring_fallback(): + asyncio.run(_test_docstring_fallback()) diff --git a/tests/test_di.py b/tests/test_di.py new file mode 100644 index 0000000..b50dcf0 --- /dev/null +++ b/tests/test_di.py @@ -0,0 +1,129 @@ +import sys +import os +import threading +import time +import pytest + +# Ensure parent directory is in sys.path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from nitrostack.core.di import DIContainer, injectable +from nitrostack.core.errors import DependencyResolutionError, CircularDependencyError + +# Reset the DIContainer before each test +@pytest.fixture(autouse=True) +def reset_container(): + DIContainer.reset() + yield + DIContainer.reset() + +def test_basic_auto_resolution(): + @injectable() + class DependencyA: + def __init__(self): + self.value = "A" + + @injectable() + class DependencyB: + def __init__(self, dep_a: DependencyA): + self.dep_a = dep_a + self.value = "B" + + container = DIContainer.get_instance() + b = container.resolve(DependencyB) + + assert b.value == "B" + assert b.dep_a.value == "A" + # Ensure they are singletons + assert container.resolve(DependencyA) is b.dep_a + +def test_auto_resolution_with_defaults(): + @injectable() + class DependencyWithDefault: + def __init__(self, name: str = "default_name"): + self.name = name + + container = DIContainer.get_instance() + instance = container.resolve(DependencyWithDefault) + assert instance.name == "default_name" + +def test_custom_token_mapping(): + @injectable(provide="DatabaseConnection") + class PostgresConnection: + def __init__(self): + self.conn_str = "postgresql://localhost" + + @injectable() + class QueryRunner: + def __init__(self, db: "DatabaseConnection"): + self.db = db + + container = DIContainer.get_instance() + + # Check resolving via string token + db_instance = container.resolve("DatabaseConnection") + assert db_instance.conn_str == "postgresql://localhost" + + # Check resolving via the class itself (it should map to the same singleton instance) + db_class_instance = container.resolve(PostgresConnection) + assert db_class_instance is db_instance + + # Check injection into QueryRunner + runner = container.resolve(QueryRunner) + assert runner.db is db_instance + +def test_circular_dependency_detection(): + # Setup circular dependency: ServiceA -> ServiceB -> ServiceA + @injectable() + class ServiceB: + pass + + @injectable() + class ServiceA: + def __init__(self, b: ServiceB): + self.b = b + + # Manually configure ServiceB to depend on ServiceA to create the cycle + ServiceB._mcp_deps = [ServiceA] + + container = DIContainer.get_instance() + + with pytest.raises(CircularDependencyError) as exc_info: + container.resolve(ServiceA) + + assert "Circular dependency detected" in str(exc_info.value) + # The path should display the circular loop + assert "ServiceA" in str(exc_info.value) + assert "ServiceB" in str(exc_info.value) + +def test_thread_safety(): + @injectable() + class SlowSingleton: + def __init__(self): + # Introduce a slight delay to trigger concurrency issues if not locked + time.sleep(0.05) + self.id = threading.get_ident() + + container = DIContainer.get_instance() + instances = [] + errors = [] + + def worker(): + try: + instance = container.resolve(SlowSingleton) + instances.append(instance) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=worker) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"Errors occurred during concurrent resolution: {errors}" + assert len(instances) == 10 + # All threads must have resolved the exact same instance reference + first_instance = instances[0] + for inst in instances[1:]: + assert inst is first_instance diff --git a/tests/test_pipeline_gaps.py b/tests/test_pipeline_gaps.py new file mode 100644 index 0000000..843dfd5 --- /dev/null +++ b/tests/test_pipeline_gaps.py @@ -0,0 +1,73 @@ +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 injectable, tool, use_guards, use_middleware, ExecutionContext, DIContainer +from nitrostack.core.pipeline import run_pipeline +from nitrostack.testing import NitroTestingModule +from nitrostack import module + +class DummyInput(BaseModel): + val: int + +class FailingGuard: + async def can_activate(self, context: ExecutionContext) -> bool: + return False + +class MyMiddleware: + async def use(self, context: ExecutionContext, call_next): + context.metadata["middleware_run"] = True + return await call_next(context) + +@injectable() +class PipelineController: + @tool(name="test_val_error", input_schema=DummyInput) + async def test_val_error(self, input: DummyInput, ctx: ExecutionContext) -> str: + return f"ok: {input.val}" + + @tool(name="test_guard_error", input_schema=DummyInput) + @use_guards(FailingGuard) + async def test_guard_error(self, input: DummyInput, ctx: ExecutionContext) -> str: + return "should not reach" + + @tool(name="test_mw_success", input_schema=DummyInput) + @use_middleware(MyMiddleware) + async def test_mw_success(self, input: DummyInput, ctx: ExecutionContext) -> str: + return f"mw_run: {ctx.metadata.get('middleware_run')}" + +@module( + name="pipeline_test", + controllers=[PipelineController], + providers=[FailingGuard, MyMiddleware] +) +class PipelineTestModule: + pass + +async def _test_pipeline_gaps(): + harness = await NitroTestingModule.create(PipelineTestModule) + + # 1. Test Pydantic validation error handling + # Passing invalid inputs (string instead of int, or missing fields) + import mcp.types as types + res_val = await harness.app.mcp_server.call_tool("test_val_error", {"input": {"val": "not_an_int"}}) + assert isinstance(res_val, types.CallToolResult) + assert res_val.content[0].text.startswith("Validation failed:") + # Check that isError is True (which translates to error response in MCP) + assert res_val.isError is True + + # 2. Test Guard failure handling + res_guard = await harness.app.mcp_server.call_tool("test_guard_error", {"input": {"val": 10}}) + assert isinstance(res_guard, types.CallToolResult) + assert "Access Denied" in res_guard.content[0].text + assert res_guard.isError is True + + # 3. Test Middleware success with ASGI-like call_next signature + res_mw = await harness.call_tool("test_mw_success", {"input": {"val": 10}}) + # The result should be text block containing mw_run: True + assert res_mw == "mw_run: True" + +def test_pipeline_gaps(): + asyncio.run(_test_pipeline_gaps())