From 4152c591ea5ccadd531d0b103b8bef6fcc176ab7 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Mon, 2 Nov 2026 21:50:17 +0530 Subject: [PATCH] feat(cli): add enterprise-search and code-mode project templates (#76) --- README.md | 18 ++ nitrostack/cli/main.py | 27 +- nitrostack/cli/upgrade.py | 28 ++- nitrostack/cli/validators.py | 2 + nitrostack/templates/code-mode/.env.example | 3 + nitrostack/templates/code-mode/.gitignore | 7 + .../templates/code-mode/.python-version | 1 + nitrostack/templates/code-mode/README.md | 83 +++++++ nitrostack/templates/code-mode/app_module.py | 14 ++ nitrostack/templates/code-mode/main.py | 30 +++ .../templates/code-mode/modules/__init__.py | 0 .../templates/code-mode/modules/data.py | 15 ++ .../templates/code-mode/modules/finance.py | 24 ++ .../templates/code-mode/modules/inventory.py | 23 ++ .../templates/code-mode/modules/models.py | 9 + .../templates/code-mode/modules/support.py | 24 ++ nitrostack/templates/code-mode/pyproject.toml | 20 ++ .../templates/code-mode/requirements.txt | 2 + .../templates/code-mode/scripts/__init__.py | 1 + .../code-mode/scripts/sample-batch.js | 6 + nitrostack/templates/code-mode/uv.toml | 1 + .../templates/enterprise-search/.env.example | 3 + .../templates/enterprise-search/.gitignore | 7 + .../enterprise-search/.python-version | 1 + .../templates/enterprise-search/README.md | 60 +++++ .../templates/enterprise-search/app_module.py | 13 + .../templates/enterprise-search/main.py | 25 ++ .../enterprise-search/modules/__init__.py | 0 .../enterprise-search/modules/finance.py | 24 ++ .../enterprise-search/modules/inventory.py | 23 ++ .../enterprise-search/modules/models.py | 9 + .../enterprise-search/modules/support.py | 24 ++ .../enterprise-search/pyproject.toml | 17 ++ .../enterprise-search/requirements.txt | 1 + .../templates/enterprise-search/uv.toml | 1 + pyproject.toml | 3 +- tests/support/project_templates_probe.py | 112 +++++++++ tests/test_project_templates.py | 231 ++++++++++++++++++ uv.lock | 13 + 39 files changed, 895 insertions(+), 10 deletions(-) create mode 100644 nitrostack/templates/code-mode/.env.example create mode 100644 nitrostack/templates/code-mode/.gitignore create mode 100644 nitrostack/templates/code-mode/.python-version create mode 100644 nitrostack/templates/code-mode/README.md create mode 100644 nitrostack/templates/code-mode/app_module.py create mode 100644 nitrostack/templates/code-mode/main.py create mode 100644 nitrostack/templates/code-mode/modules/__init__.py create mode 100644 nitrostack/templates/code-mode/modules/data.py create mode 100644 nitrostack/templates/code-mode/modules/finance.py create mode 100644 nitrostack/templates/code-mode/modules/inventory.py create mode 100644 nitrostack/templates/code-mode/modules/models.py create mode 100644 nitrostack/templates/code-mode/modules/support.py create mode 100644 nitrostack/templates/code-mode/pyproject.toml create mode 100644 nitrostack/templates/code-mode/requirements.txt create mode 100644 nitrostack/templates/code-mode/scripts/__init__.py create mode 100644 nitrostack/templates/code-mode/scripts/sample-batch.js create mode 100644 nitrostack/templates/code-mode/uv.toml create mode 100644 nitrostack/templates/enterprise-search/.env.example create mode 100644 nitrostack/templates/enterprise-search/.gitignore create mode 100644 nitrostack/templates/enterprise-search/.python-version create mode 100644 nitrostack/templates/enterprise-search/README.md create mode 100644 nitrostack/templates/enterprise-search/app_module.py create mode 100644 nitrostack/templates/enterprise-search/main.py create mode 100644 nitrostack/templates/enterprise-search/modules/__init__.py create mode 100644 nitrostack/templates/enterprise-search/modules/finance.py create mode 100644 nitrostack/templates/enterprise-search/modules/inventory.py create mode 100644 nitrostack/templates/enterprise-search/modules/models.py create mode 100644 nitrostack/templates/enterprise-search/modules/support.py create mode 100644 nitrostack/templates/enterprise-search/pyproject.toml create mode 100644 nitrostack/templates/enterprise-search/requirements.txt create mode 100644 nitrostack/templates/enterprise-search/uv.toml create mode 100644 tests/support/project_templates_probe.py create mode 100644 tests/test_project_templates.py diff --git a/README.md b/README.md index 0b7a741..b3b97bc 100644 --- a/README.md +++ b/README.md @@ -554,3 +554,21 @@ resource reference without invoking the widget's data transformer. Independent HTTP/stdio tests and reproducible large-output checks live in `tests/test_spillover_transport.py`. Run the spillover tests with `python -m pytest tests/test_spillover.py tests/test_spillover_transport.py`. + +### Progressive discovery and code-mode templates + +| Template | Example | +| --- | --- | +| `python-enterprise-search` | BM25 discovery over local finance, inventory and support tools | +| `python-code-mode` | Bounded JavaScript workflows calling Python tools through native QuickJS | + +```sh +nitrostack-py init search-demo --template python-enterprise-search --skip-install +nitrostack-py init code-demo --template python-code-mode --skip-install +``` + +Each generated README explains installation, tool calls, validation, upgrade and +packaging. Both use deterministic sample data without credentials or external +services. Code-mode execution additionally requires `quickjs>=1.19.4`; its workers +start lazily and are disposed on graceful shutdown. Existing starter, pizzaz and +OAuth templates remain available. diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index 1ab03b3..c3853a3 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -850,6 +850,8 @@ async def booking_guide(self, context: ExecutionContext) -> str: "python-starter": "starter", "python-pizzaz": "pizzaz", "python-oauth": "flight-booking", + "python-enterprise-search": "enterprise-search", + "python-code-mode": "code-mode", } REQUIREMENTS_TEMPLATE = """nitrostack @@ -1107,18 +1109,22 @@ def _prompt_template() -> str: print(" \033[34m1. python-starter\033[0m Starter — simple calculator for learning basics") print(" \033[34m2. python-pizzaz\033[0m Advanced — pizza shop finder with maps & widgets") print(" \033[34m3. python-oauth\033[0m Flight booking with OAuth 2.1 auth") + print(" \033[34m4. python-enterprise-search\033[0m BM25 progressive tool discovery") + print(" \033[34m5. python-code-mode\033[0m QuickJS multi-tool workflows") by_choice = { "1": "python-starter", "2": "python-pizzaz", "3": "python-oauth", + "4": "python-enterprise-search", + "5": "python-code-mode", } while True: - choice = _prompt("Enter choice (1-3) or template name", "1") + choice = _prompt("Enter choice (1-5) or template name", "1") if choice in by_choice: return by_choice[choice] if choice.lower() in OFFICIAL_TEMPLATES: return choice.lower() - print("Please enter 1, 2, 3, or an explicit template name (python-starter, python-pizzaz, python-oauth).") + print("Please enter 1-5, or an explicit template name (" + ", ".join(OFFICIAL_TEMPLATES) + ").") def _normalize_port(value, label): @@ -1310,6 +1316,9 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa # 7. Update .env file env_path = os.path.join(name, ".env") + env_example_path = os.path.join(name, ".env.example") + if not os.path.exists(env_path) and os.path.isfile(env_example_path): + shutil.copyfile(env_example_path, env_path) if os.path.exists(env_path): with open(env_path, "r", encoding="utf-8") as f: lines = f.readlines() @@ -1398,6 +1407,18 @@ def init_project(name: str = None, template: str = None, skip_install: bool = Fa # Next Steps print("\n\033[1;37mNext steps:\033[0m") print(f" 1. \033[34mcd {name}\033[0m") + if template in ("python-enterprise-search", "python-code-mode"): + print(" 2. Read README.md for sample tool calls and local demonstration data") + print(" 3. Validate the project: nitrostack-py validate") + print(" 4. Install dependencies if skipped: nitrostack-py install") + print(" 5. Start an MCP stdio server: python main.py") + print(" For HTTP set MCP_TRANSPORT_TYPE=http, then connect to http://localhost:3000/mcp") + if template == "python-code-mode": + print(" Try scripts/sample-batch.js through execute; QuickJS must be installed") + else: + print(" Try search_tools with 'overdue invoices', then invoke it through call_tool") + print("\nHappy coding! 🚀\n") + return if template == "python-oauth": print(" 2. Configure OAuth credentials in your \033[34m.env\033[0m file") print(" See \033[34mOAUTH_SETUP.md\033[0m for provider guides") @@ -1746,7 +1767,7 @@ def main(): "--template", choices=list(OFFICIAL_TEMPLATES.keys()), default=None, - help="Template to use: python-starter, python-pizzaz, python-oauth (default: interactive prompt)", + help="Template to use: " + ", ".join(OFFICIAL_TEMPLATES) + " (default: interactive prompt)", ) init_parser.add_argument("--skip-install", action="store_true", help="Skip installing Python (and widget npm) dependencies") init_parser.add_argument( diff --git a/nitrostack/cli/upgrade.py b/nitrostack/cli/upgrade.py index 12a312a..4b6bf3d 100644 --- a/nitrostack/cli/upgrade.py +++ b/nitrostack/cli/upgrade.py @@ -75,20 +75,36 @@ def verify_nitrostack_version(version: str, timeout: float = 15.0) -> None: raise UpgradeError(f"Could not reach PyPI to verify nitrostack=={version}: {exc}") from exc +def _is_source_reference(text: str, match: re.Match) -> bool: + """Keep uv source-table keys and local source paths out of version upgrades.""" + tables = re.findall(r"(?m)^\s*\[([^\]\n]+)\]", text[:match.start()]) + if tables and (tables[-1] == "tool.uv.sources" or tables[-1].startswith("tool.uv.sources.")): + return True + return bool(re.match(r"\s*=(?!=)", text[match.end():])) or ( + match.start() > 0 and text[match.start() - 1] in "/\\" + ) + + def find_current_spec(text: str) -> Optional[str]: - match = _DEP_RE.search(text) - if not match: - return None - spec = (match.group(2) + (match.group(3) or "")).strip() - return spec + for match in _DEP_RE.finditer(text): + # A [tool.uv.sources] key is not a dependency requirement. + if not _is_source_reference(text, match): + return (match.group(2) + (match.group(3) or "")).strip() + return None def replace_nitrostack_spec(text: str, spec: str) -> Tuple[str, int]: + count = 0 def _sub(match: re.Match) -> str: + nonlocal count + if _is_source_reference(text, match): + return match.group(0) + count += 1 quote = match.group(1) or "" return f"{quote}{spec}{quote}" - return _DEP_RE.subn(_sub, text) + updated = _DEP_RE.sub(_sub, text) + return updated, count def _add_to_pyproject_dependencies(text: str, spec: str) -> str: diff --git a/nitrostack/cli/validators.py b/nitrostack/cli/validators.py index 990b154..73836a3 100644 --- a/nitrostack/cli/validators.py +++ b/nitrostack/cli/validators.py @@ -39,10 +39,12 @@ def format(self) -> str: def _iter_python_files(root: str) -> Iterable[str]: + vendored_sdk = os.path.join(os.path.abspath(root), "vendor", "nitrostack") for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [ d for d in dirnames if d not in EXCLUDE_DIR_NAMES and not d.endswith(".egg-info") + and os.path.join(os.path.abspath(dirpath), d) != vendored_sdk ] for filename in filenames: if filename.endswith(".py"): diff --git a/nitrostack/templates/code-mode/.env.example b/nitrostack/templates/code-mode/.env.example new file mode 100644 index 0000000..43dc223 --- /dev/null +++ b/nitrostack/templates/code-mode/.env.example @@ -0,0 +1,3 @@ +PORT=3000 +# Default transport is stdio. Set MCP_TRANSPORT_TYPE=http to serve /mcp. +# No credentials are needed: all tools use local demonstration data. diff --git a/nitrostack/templates/code-mode/.gitignore b/nitrostack/templates/code-mode/.gitignore new file mode 100644 index 0000000..56dc8d5 --- /dev/null +++ b/nitrostack/templates/code-mode/.gitignore @@ -0,0 +1,7 @@ +.env +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +build/ +dist/ diff --git a/nitrostack/templates/code-mode/.python-version b/nitrostack/templates/code-mode/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/nitrostack/templates/code-mode/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/nitrostack/templates/code-mode/README.md b/nitrostack/templates/code-mode/README.md new file mode 100644 index 0000000..5290bf6 --- /dev/null +++ b/nitrostack/templates/code-mode/README.md @@ -0,0 +1,83 @@ +# Code mode + +Compose several Python tool calls in one JavaScript workflow using native +QuickJS inside isolated worker processes. This Python implementation does not +use WebAssembly. +## Run the generated project + +```sh +nitrostack-py install +nitrostack-py validate +python main.py +``` + +`python main.py` serves MCP over stdio: connect it to an MCP client rather than +expecting a browser page. For HTTP set `MCP_TRANSPORT_TYPE=http` in your shell, +then run `python main.py` and connect to `http://localhost:3000/mcp`. +On PowerShell use `$env:MCP_TRANSPORT_TYPE="http"`; on a POSIX shell use +`MCP_TRANSPORT_TYPE=http python main.py`. Do not expose this unauthenticated demo +on a public interface. No credentials or external services are required. + +The CLI creates `.env` from `.env.example`; adjust it for optional configuration. +Every finance, inventory and support record is fixed demonstration data. +The code supports Python 3.10+; `.python-version` selects 3.12 for uv. + +## Discover and execute + +The exposed catalog is `search`, `get_schema`, and `execute`. The seven underlying +read-only finance, inventory, support and numeric tools remain discoverable. + +1. Call `search` with `{ "query": "inventory" }` (or an empty query to browse). +2. Call `get_schema` with `{ "name": "inventory_check_stock" }`. +3. Read `scripts/sample-batch.js` and pass its text as the `code` argument to + `execute`. The script checks two SKUs and sums their quantities: + +```javascript +const stock = ["keyboard", "monitor"].map(sku => + tools.call("inventory_check_stock", {sku}) +); +const summary = tools.call("data_aggregate_sum", {values: stock.map(item => item.quantity)}); +return {stock, total_units: summary.total}; +``` + +The successful execution result has `success: true`, `value.total_units: 18`, +and `toolCallCount: 3`. `tools.call` is synchronous. Scripts are function bodies +and may use `return`; Node.js modules, `fetch`, browser APIs and top-level `await` +are unavailable. Python handles each tool invocation through the SDK pipeline. + +## Installation and limits + +Both dependency files declare `nitrostack` and `quickjs>=1.19.4`. Install the +project dependencies before executing scripts. Native QuickJS wheel availability +varies by Python/platform; a source install may require a C build toolchain. +If your Windows environment cannot build it, use a supported Linux/WSL environment. +Generation, import, discovery and validation do not create workers; the first +`execute` initializes the pool lazily. + +`main.py` sets a 15-second timeout, 100 MiB guest memory limit, 20 tool calls, +one worker process and `allow_destructive=False`. Tools explicitly declare +read-only annotations. Execution is bounded by the SDK, but these process and +runtime limits are not a complete security boundary for arbitrary hostile code. +Run only trusted demonstration workflows. Resource limits do not replace tool +permissions or authentication. + +The entry point awaits `CODE_MODE.dispose()` in `finally`, including failed +startup, so workers are stopped on graceful shutdown. If embedding this app, +retain that cleanup. Do not construct a pool at import time. + +## Extend and distribute + +Register additional tools in `app_module.py`. Keep destructive operations disabled +unless your application deliberately adds authorization and a reviewed policy. + +```sh +nitrostack-py upgrade --version +nitrostack-py validate +nitrostack-py pack +``` + +`upgrade` edits dependency requirements; run `nitrostack-py install` afterward. +The standalone QuickJS requirement remains in place across SDK upgrades. +`pack` includes the application modules and packaged JavaScript example. +Generation vendors the running SDK under `vendor/nitrostack`; uv resolves that +copy through `[tool.uv.sources]`. diff --git a/nitrostack/templates/code-mode/app_module.py b/nitrostack/templates/code-mode/app_module.py new file mode 100644 index 0000000..36c2bcb --- /dev/null +++ b/nitrostack/templates/code-mode/app_module.py @@ -0,0 +1,14 @@ +from nitrostack import ConfigModule, module +from modules.finance import FinanceTools +from modules.inventory import InventoryTools +from modules.support import SupportTools +from modules.data import DataTools + + +@module( + name="app", + imports=[ConfigModule.for_root(env_file_path=".env", defaults={"PORT": "3000"})], + controllers=[FinanceTools, InventoryTools, SupportTools, DataTools], +) +class AppModule: + pass diff --git a/nitrostack/templates/code-mode/main.py b/nitrostack/templates/code-mode/main.py new file mode 100644 index 0000000..29bf8df --- /dev/null +++ b/nitrostack/templates/code-mode/main.py @@ -0,0 +1,30 @@ +"""Code-mode server; importing this module never starts workers or transports.""" +import asyncio +from nitrostack import McpApplicationFactory, ServerConfig, mcp_app +from nitrostack.transforms.code_mode import CodeModeTransform +from app_module import AppModule + +CODE_MODE = CodeModeTransform( + timeout_ms=15000, + memory_limit_mb=100, + max_tool_calls=20, + worker_pool_size=1, + allow_destructive=False, +) + + +@mcp_app(module=AppModule, server=ServerConfig(name="code-mode"), transforms=[CODE_MODE]) +class Application: + pass + + +async def main(): + try: + app = await McpApplicationFactory.create(Application) + await app.start() + finally: + await CODE_MODE.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/nitrostack/templates/code-mode/modules/__init__.py b/nitrostack/templates/code-mode/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nitrostack/templates/code-mode/modules/data.py b/nitrostack/templates/code-mode/modules/data.py new file mode 100644 index 0000000..666d716 --- /dev/null +++ b/nitrostack/templates/code-mode/modules/data.py @@ -0,0 +1,15 @@ +"""One numerical micro-tool for composing a multi-tool script.""" +from nitrostack import ExecutionContext, injectable, tool +from pydantic import BaseModel, Field, FiniteFloat +from modules.models import READ_ONLY + + +class SumInput(BaseModel): + values: list[FiniteFloat] = Field(description="Finite numbers to sum", max_length=1000) + + +@injectable() +class DataTools: + @tool(name="data_aggregate_sum", description="Sum a list of numeric values from earlier tool results", input_schema=SumInput, annotations=READ_ONLY) + async def aggregate_sum(self, input: SumInput, context: ExecutionContext) -> dict: + return {"total": sum(input.values)} diff --git a/nitrostack/templates/code-mode/modules/finance.py b/nitrostack/templates/code-mode/modules/finance.py new file mode 100644 index 0000000..d7a1630 --- /dev/null +++ b/nitrostack/templates/code-mode/modules/finance.py @@ -0,0 +1,24 @@ +"""Fixed demonstration invoices; no banking or payment integration.""" +from nitrostack import ExecutionContext, injectable, tool +from pydantic import BaseModel, Field +from modules.models import EmptyInput, READ_ONLY + +INVOICES = ( + {"id": "INV-001", "customer": "Acme", "amount": 1200, "overdue": True}, + {"id": "INV-002", "customer": "Example Co", "amount": 800, "overdue": False}, +) + + +class InvoiceInput(BaseModel): + overdue_only: bool = Field(default=False, description="Return only overdue unpaid invoices") + + +@injectable() +class FinanceTools: + @tool(name="finance_get_financial_summary", description="Summarize demonstration revenue and invoice totals", input_schema=EmptyInput, annotations=READ_ONLY) + async def summary(self, input: EmptyInput, context: ExecutionContext) -> dict: + return {"invoice_count": len(INVOICES), "total_amount": sum(row["amount"] for row in INVOICES)} + + @tool(name="finance_list_invoices", description="Find unpaid invoices, including overdue customer invoices", input_schema=InvoiceInput, annotations=READ_ONLY) + async def invoices(self, input: InvoiceInput, context: ExecutionContext) -> dict: + return {"invoices": [dict(row) for row in INVOICES if not input.overdue_only or row["overdue"]]} diff --git a/nitrostack/templates/code-mode/modules/inventory.py b/nitrostack/templates/code-mode/modules/inventory.py new file mode 100644 index 0000000..7310a9f --- /dev/null +++ b/nitrostack/templates/code-mode/modules/inventory.py @@ -0,0 +1,23 @@ +"""Deterministic sample inventory, shared by discovery and orchestration examples.""" +from nitrostack import ExecutionContext, injectable, tool +from pydantic import BaseModel, Field +from modules.models import EmptyInput, READ_ONLY + +STOCK = {"keyboard": 12, "monitor": 6} + + +class StockInput(BaseModel): + sku: str = Field(description="Product SKU: keyboard or monitor") + + +@injectable() +class InventoryTools: + @tool(name="inventory_check_stock", description="Look up available stock quantity for a product SKU", input_schema=StockInput, annotations=READ_ONLY) + async def check_stock(self, input: StockInput, context: ExecutionContext) -> dict: + if input.sku not in STOCK: + raise ValueError("Unknown demonstration SKU; use keyboard or monitor") + return {"sku": input.sku, "quantity": STOCK[input.sku]} + + @tool(name="inventory_report", description="Report all demonstration warehouse inventory quantities", input_schema=EmptyInput, annotations=READ_ONLY) + async def report(self, input: EmptyInput, context: ExecutionContext) -> dict: + return {"stock": dict(STOCK)} diff --git a/nitrostack/templates/code-mode/modules/models.py b/nitrostack/templates/code-mode/modules/models.py new file mode 100644 index 0000000..0600160 --- /dev/null +++ b/nitrostack/templates/code-mode/modules/models.py @@ -0,0 +1,9 @@ +"""Shared schemas and read-only annotations for the demonstration tools.""" +from nitrostack import ToolAnnotations +from pydantic import BaseModel + +READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, open_world_hint=False) + + +class EmptyInput(BaseModel): + pass diff --git a/nitrostack/templates/code-mode/modules/support.py b/nitrostack/templates/code-mode/modules/support.py new file mode 100644 index 0000000..4baac16 --- /dev/null +++ b/nitrostack/templates/code-mode/modules/support.py @@ -0,0 +1,24 @@ +"""Local sample support tickets; no authentication or ticketing service.""" +from nitrostack import ExecutionContext, injectable, tool +from pydantic import BaseModel, Field +from modules.models import EmptyInput, READ_ONLY + +TICKETS = ( + {"id": "T-001", "title": "Keyboard delivery delayed", "status": "open"}, + {"id": "T-002", "title": "Monitor setup question", "status": "resolved"}, +) + + +class TicketInput(BaseModel): + query: str = Field(default="", description="Words to find in a support ticket title") + + +@injectable() +class SupportTools: + @tool(name="support_get_system_status", description="Check demonstration customer support system health", input_schema=EmptyInput, annotations=READ_ONLY) + async def status(self, input: EmptyInput, context: ExecutionContext) -> dict: + return {"status": "ok", "demo": True} + + @tool(name="support_search_tickets", description="Search customer support tickets about deliveries and product setup", input_schema=TicketInput, annotations=READ_ONLY) + async def tickets(self, input: TicketInput, context: ExecutionContext) -> dict: + return {"tickets": [dict(row) for row in TICKETS if input.query.casefold() in row["title"].casefold()]} diff --git a/nitrostack/templates/code-mode/pyproject.toml b/nitrostack/templates/code-mode/pyproject.toml new file mode 100644 index 0000000..eb61367 --- /dev/null +++ b/nitrostack/templates/code-mode/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=61.0.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "nitrostack-code-mode" +version = "0.1.0" +description = "NitroStack code-mode demonstration server" +requires-python = ">=3.10" +dependencies = ["nitrostack", "quickjs>=1.19.4"] + +[tool.setuptools] +py-modules = ["main", "app_module"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["modules*", "scripts*"] + +[tool.setuptools.package-data] +scripts = ["*.js"] diff --git a/nitrostack/templates/code-mode/requirements.txt b/nitrostack/templates/code-mode/requirements.txt new file mode 100644 index 0000000..06cc154 --- /dev/null +++ b/nitrostack/templates/code-mode/requirements.txt @@ -0,0 +1,2 @@ +nitrostack +quickjs>=1.19.4 diff --git a/nitrostack/templates/code-mode/scripts/__init__.py b/nitrostack/templates/code-mode/scripts/__init__.py new file mode 100644 index 0000000..715d0ce --- /dev/null +++ b/nitrostack/templates/code-mode/scripts/__init__.py @@ -0,0 +1 @@ +"""Packaged JavaScript examples for the code-mode server.""" diff --git a/nitrostack/templates/code-mode/scripts/sample-batch.js b/nitrostack/templates/code-mode/scripts/sample-batch.js new file mode 100644 index 0000000..abc31ab --- /dev/null +++ b/nitrostack/templates/code-mode/scripts/sample-batch.js @@ -0,0 +1,6 @@ +// Calls are synchronous; no Node.js, fetch(), imports, or browser APIs are available. +const stock = ["keyboard", "monitor"].map(sku => + tools.call("inventory_check_stock", {sku}) +); +const summary = tools.call("data_aggregate_sum", {values: stock.map(item => item.quantity)}); +return {stock, total_units: summary.total}; diff --git a/nitrostack/templates/code-mode/uv.toml b/nitrostack/templates/code-mode/uv.toml new file mode 100644 index 0000000..39128ea --- /dev/null +++ b/nitrostack/templates/code-mode/uv.toml @@ -0,0 +1 @@ +python-preference = "managed" diff --git a/nitrostack/templates/enterprise-search/.env.example b/nitrostack/templates/enterprise-search/.env.example new file mode 100644 index 0000000..43dc223 --- /dev/null +++ b/nitrostack/templates/enterprise-search/.env.example @@ -0,0 +1,3 @@ +PORT=3000 +# Default transport is stdio. Set MCP_TRANSPORT_TYPE=http to serve /mcp. +# No credentials are needed: all tools use local demonstration data. diff --git a/nitrostack/templates/enterprise-search/.gitignore b/nitrostack/templates/enterprise-search/.gitignore new file mode 100644 index 0000000..56dc8d5 --- /dev/null +++ b/nitrostack/templates/enterprise-search/.gitignore @@ -0,0 +1,7 @@ +.env +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +build/ +dist/ diff --git a/nitrostack/templates/enterprise-search/.python-version b/nitrostack/templates/enterprise-search/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/nitrostack/templates/enterprise-search/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/nitrostack/templates/enterprise-search/README.md b/nitrostack/templates/enterprise-search/README.md new file mode 100644 index 0000000..4cdd812 --- /dev/null +++ b/nitrostack/templates/enterprise-search/README.md @@ -0,0 +1,60 @@ +# Enterprise search + +A small MCP service demonstrating BM25-ranked progressive tool discovery. +## Run the generated project + +```sh +nitrostack-py install +nitrostack-py validate +python main.py +``` + +`python main.py` serves MCP over stdio: connect it to an MCP client rather than +expecting a browser page. For HTTP set `MCP_TRANSPORT_TYPE=http` in your shell, +then run `python main.py` and connect to `http://localhost:3000/mcp`. +On PowerShell use `$env:MCP_TRANSPORT_TYPE="http"`; on a POSIX shell use +`MCP_TRANSPORT_TYPE=http python main.py`. Do not expose this unauthenticated demo +on a public interface. No credentials or external services are required. + +The CLI creates `.env` from `.env.example`; adjust it for optional configuration. +Every finance, inventory and support record is fixed demonstration data. +The code supports Python 3.10+; `.python-version` selects 3.12 for uv. + +## Discover, then call + +The initial tool list contains `search_tools`, `call_tool`, and three anchors: +`finance_get_financial_summary`, `inventory_report`, `support_get_system_status`. +The full six-tool catalog is available through progressive discovery. +BM25 ranks tool descriptions and schemas; it does not search an external +enterprise document database. + +In an MCP client's tool-call interface: + +1. Call `search_tools` with `{ "query": "" }` to browse the catalog. +2. Call `search_tools` with `{ "query": "overdue invoices" }`. The invoice tool + appears first, with its input schema. A query with no matches returns no tools. +3. Call `call_tool` with: + +```json +{"name": "finance_list_invoices", "arguments": {"overdue_only": true}} +``` + +The result contains `INV-001`, the overdue Acme invoice. Tool arguments use +schema fields directly; do not add an `input` wrapper. + +## Extend and distribute + +Add decorated read-only tools under `modules/`, register their classes in +`app_module.py`, and keep searchable names and descriptions specific. +`main.py` configures `Bm25SearchTransform` and the always-visible anchors. +Each read returns new containers so a client cannot mutate the sample records. + +```sh +nitrostack-py upgrade --version +nitrostack-py validate +nitrostack-py pack +``` + +`upgrade` edits dependency requirements; run `nitrostack-py install` afterward. +`pack` writes a wheel under `dist/`. Generation vendors the running SDK under +`vendor/nitrostack`; uv resolves that local copy through `[tool.uv.sources]`. diff --git a/nitrostack/templates/enterprise-search/app_module.py b/nitrostack/templates/enterprise-search/app_module.py new file mode 100644 index 0000000..6a0acbb --- /dev/null +++ b/nitrostack/templates/enterprise-search/app_module.py @@ -0,0 +1,13 @@ +from nitrostack import ConfigModule, module +from modules.finance import FinanceTools +from modules.inventory import InventoryTools +from modules.support import SupportTools + + +@module( + name="app", + imports=[ConfigModule.for_root(env_file_path=".env", defaults={"PORT": "3000"})], + controllers=[FinanceTools, InventoryTools, SupportTools], +) +class AppModule: + pass diff --git a/nitrostack/templates/enterprise-search/main.py b/nitrostack/templates/enterprise-search/main.py new file mode 100644 index 0000000..6aacb0b --- /dev/null +++ b/nitrostack/templates/enterprise-search/main.py @@ -0,0 +1,25 @@ +"""BM25 progressive-discovery server with local sample tools.""" +import asyncio +from nitrostack import McpApplicationFactory, ServerConfig, mcp_app +from nitrostack.transforms.search import Bm25SearchTransform +from app_module import AppModule + + +@mcp_app( + module=AppModule, + server=ServerConfig(name="enterprise-search"), + transforms=[Bm25SearchTransform(always_visible=[ + "finance_get_financial_summary", "inventory_report", "support_get_system_status", + ])], +) +class Application: + pass + + +async def main(): + app = await McpApplicationFactory.create(Application) + await app.start() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/nitrostack/templates/enterprise-search/modules/__init__.py b/nitrostack/templates/enterprise-search/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nitrostack/templates/enterprise-search/modules/finance.py b/nitrostack/templates/enterprise-search/modules/finance.py new file mode 100644 index 0000000..d7a1630 --- /dev/null +++ b/nitrostack/templates/enterprise-search/modules/finance.py @@ -0,0 +1,24 @@ +"""Fixed demonstration invoices; no banking or payment integration.""" +from nitrostack import ExecutionContext, injectable, tool +from pydantic import BaseModel, Field +from modules.models import EmptyInput, READ_ONLY + +INVOICES = ( + {"id": "INV-001", "customer": "Acme", "amount": 1200, "overdue": True}, + {"id": "INV-002", "customer": "Example Co", "amount": 800, "overdue": False}, +) + + +class InvoiceInput(BaseModel): + overdue_only: bool = Field(default=False, description="Return only overdue unpaid invoices") + + +@injectable() +class FinanceTools: + @tool(name="finance_get_financial_summary", description="Summarize demonstration revenue and invoice totals", input_schema=EmptyInput, annotations=READ_ONLY) + async def summary(self, input: EmptyInput, context: ExecutionContext) -> dict: + return {"invoice_count": len(INVOICES), "total_amount": sum(row["amount"] for row in INVOICES)} + + @tool(name="finance_list_invoices", description="Find unpaid invoices, including overdue customer invoices", input_schema=InvoiceInput, annotations=READ_ONLY) + async def invoices(self, input: InvoiceInput, context: ExecutionContext) -> dict: + return {"invoices": [dict(row) for row in INVOICES if not input.overdue_only or row["overdue"]]} diff --git a/nitrostack/templates/enterprise-search/modules/inventory.py b/nitrostack/templates/enterprise-search/modules/inventory.py new file mode 100644 index 0000000..7310a9f --- /dev/null +++ b/nitrostack/templates/enterprise-search/modules/inventory.py @@ -0,0 +1,23 @@ +"""Deterministic sample inventory, shared by discovery and orchestration examples.""" +from nitrostack import ExecutionContext, injectable, tool +from pydantic import BaseModel, Field +from modules.models import EmptyInput, READ_ONLY + +STOCK = {"keyboard": 12, "monitor": 6} + + +class StockInput(BaseModel): + sku: str = Field(description="Product SKU: keyboard or monitor") + + +@injectable() +class InventoryTools: + @tool(name="inventory_check_stock", description="Look up available stock quantity for a product SKU", input_schema=StockInput, annotations=READ_ONLY) + async def check_stock(self, input: StockInput, context: ExecutionContext) -> dict: + if input.sku not in STOCK: + raise ValueError("Unknown demonstration SKU; use keyboard or monitor") + return {"sku": input.sku, "quantity": STOCK[input.sku]} + + @tool(name="inventory_report", description="Report all demonstration warehouse inventory quantities", input_schema=EmptyInput, annotations=READ_ONLY) + async def report(self, input: EmptyInput, context: ExecutionContext) -> dict: + return {"stock": dict(STOCK)} diff --git a/nitrostack/templates/enterprise-search/modules/models.py b/nitrostack/templates/enterprise-search/modules/models.py new file mode 100644 index 0000000..0600160 --- /dev/null +++ b/nitrostack/templates/enterprise-search/modules/models.py @@ -0,0 +1,9 @@ +"""Shared schemas and read-only annotations for the demonstration tools.""" +from nitrostack import ToolAnnotations +from pydantic import BaseModel + +READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, open_world_hint=False) + + +class EmptyInput(BaseModel): + pass diff --git a/nitrostack/templates/enterprise-search/modules/support.py b/nitrostack/templates/enterprise-search/modules/support.py new file mode 100644 index 0000000..4baac16 --- /dev/null +++ b/nitrostack/templates/enterprise-search/modules/support.py @@ -0,0 +1,24 @@ +"""Local sample support tickets; no authentication or ticketing service.""" +from nitrostack import ExecutionContext, injectable, tool +from pydantic import BaseModel, Field +from modules.models import EmptyInput, READ_ONLY + +TICKETS = ( + {"id": "T-001", "title": "Keyboard delivery delayed", "status": "open"}, + {"id": "T-002", "title": "Monitor setup question", "status": "resolved"}, +) + + +class TicketInput(BaseModel): + query: str = Field(default="", description="Words to find in a support ticket title") + + +@injectable() +class SupportTools: + @tool(name="support_get_system_status", description="Check demonstration customer support system health", input_schema=EmptyInput, annotations=READ_ONLY) + async def status(self, input: EmptyInput, context: ExecutionContext) -> dict: + return {"status": "ok", "demo": True} + + @tool(name="support_search_tickets", description="Search customer support tickets about deliveries and product setup", input_schema=TicketInput, annotations=READ_ONLY) + async def tickets(self, input: TicketInput, context: ExecutionContext) -> dict: + return {"tickets": [dict(row) for row in TICKETS if input.query.casefold() in row["title"].casefold()]} diff --git a/nitrostack/templates/enterprise-search/pyproject.toml b/nitrostack/templates/enterprise-search/pyproject.toml new file mode 100644 index 0000000..40c9ff5 --- /dev/null +++ b/nitrostack/templates/enterprise-search/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=61.0.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "nitrostack-enterprise-search" +version = "0.1.0" +description = "NitroStack enterprise-search demonstration server" +requires-python = ">=3.10" +dependencies = ["nitrostack"] + +[tool.setuptools] +py-modules = ["main", "app_module"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["modules*", "scripts*"] diff --git a/nitrostack/templates/enterprise-search/requirements.txt b/nitrostack/templates/enterprise-search/requirements.txt new file mode 100644 index 0000000..9e875ce --- /dev/null +++ b/nitrostack/templates/enterprise-search/requirements.txt @@ -0,0 +1 @@ +nitrostack diff --git a/nitrostack/templates/enterprise-search/uv.toml b/nitrostack/templates/enterprise-search/uv.toml new file mode 100644 index 0000000..39128ea --- /dev/null +++ b/nitrostack/templates/enterprise-search/uv.toml @@ -0,0 +1 @@ +python-preference = "managed" diff --git a/pyproject.toml b/pyproject.toml index 5c91683..d484599 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ code-mode = [ "quickjs>=1.19.4", ] dev = [ + "setuptools>=70.1", "quickjs>=1.19.4", "pytest-cov>=4.1.0", "pytest>=8", @@ -91,4 +92,4 @@ exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "if __name__ == .__main__.:", -] \ No newline at end of file +] diff --git a/tests/support/project_templates_probe.py b/tests/support/project_templates_probe.py new file mode 100644 index 0000000..510ca0e --- /dev/null +++ b/tests/support/project_templates_probe.py @@ -0,0 +1,112 @@ +"""Subprocess entry points for generated projects; never contacts external services.""" +from __future__ import annotations + +import asyncio +import importlib.util +import json +import multiprocessing +import sys +from pathlib import Path +from unittest.mock import AsyncMock, patch + + +def cli(args): + from nitrostack.cli import skills, upgrade + from nitrostack.cli.main import main + + def offline(): + raise skills.SkillsCloneError("offline template test") + + skills.clone_skills_repo = offline + upgrade.verify_nitrostack_version = lambda *args, **kwargs: None + upgrade.fetch_latest_nitrostack_version = lambda *args, **kwargs: "0.3.2" + sys.argv = ["nitrostack-py", *args] + main() + + +async def behavior(kind): + sys.path.insert(0, str(Path.cwd())) + # Import/bootstrap must not start transports or sandbox workers. + from nitrostack.transforms.code_mode.worker_pool import WorkerPool + with patch.object(WorkerPool, "__init__", side_effect=AssertionError("eager worker")): + import main as project + from nitrostack import McpApplicationFactory + app = await McpApplicationFactory.create(project.Application) + assert multiprocessing.active_children() == [] + import mcp.types as types + from nitrostack.testing import NitroTestingModule + harness = NitroTestingModule(app) + listed = await app.mcp_server.request_handlers[types.ListToolsRequest](types.ListToolsRequest(method="tools/list")) + names = {t.name for t in listed.root.tools} + if kind == "enterprise-search": + assert names == {"search_tools", "call_tool", "finance_get_financial_summary", "inventory_report", "support_get_system_status"} + browse = await harness.call_tool("search_tools", {"query": ""}) + assert browse["total_tools"] == 6 + ranked = await harness.call_tool("search_tools", {"query": "overdue invoices"}) + assert ranked["results"][0]["name"] == "finance_list_invoices" + assert ranked == await harness.call_tool("search_tools", {"query": "overdue invoices"}) + assert (await harness.call_tool("search_tools", {"query": "zzzznothingmatches"}))["results"] == [] + result = await harness.call_tool("call_tool", {"name": "finance_list_invoices", "arguments": {"overdue_only": True}}) + assert [row["id"] for row in result["invoices"]] == ["INV-001"] + result["invoices"][0]["amount"] = 0 + result = await harness.call_tool("call_tool", {"name": "finance_list_invoices", "arguments": {"overdue_only": True}}) + assert result["invoices"][0]["amount"] == 1200 + else: + assert names == {"search", "get_schema", "execute"} + search = await harness.call_tool("search", {"query": "inventory"}) + assert search["total_tools"] == 7 + assert {tool["name"] for tool in search["tools"]} == {"inventory_check_stock", "inventory_report"} + schema = await harness.call_tool("get_schema", {"name": "inventory_check_stock"}) + stock_schema = schema["schemas"]["inventory_check_stock"]["inputSchema"] + assert stock_schema["properties"]["sku"]["type"] == "string" + assert "sku" in stock_schema["required"] + assert project.CODE_MODE._sandbox is None + try: + if sys.platform == "win32" and importlib.util.find_spec("quickjs") is None: + # Exercise the documented unavailable-binding error, never pretend a fake is QuickJS. + result = await harness.call_tool("execute", {"code": "return 1;"}) + assert "required for Code Mode" in json.dumps(result) + print("NATIVE_QUICKJS_UNAVAILABLE: verified diagnostic; real execution runs on Linux") + else: + result = await harness.call_tool("execute", {"code": Path("scripts/sample-batch.js").read_text()}) + assert result["success"] is True, result + assert result["value"]["total_units"] == 18 + assert result["toolCallCount"] == 3 + pool = project.CODE_MODE.sandbox + workers = [slot.process for slot in pool._all_slots] + project.CODE_MODE.limits.timeout_ms = 200 + failure = await harness.call_tool("execute", {"code": "while (true) {}"}) + assert failure["success"] is False, failure + await project.CODE_MODE.dispose() + assert all(not worker.is_alive() for worker in workers) + assert multiprocessing.active_children() == [] + finally: + await project.CODE_MODE.dispose() + print("BEHAVIOR_OK", kind) + + +async def cleanup(): + sys.path.insert(0, str(Path.cwd())) + import main as project + from types import SimpleNamespace + for fail_creation in (True, False): + create = AsyncMock(side_effect=RuntimeError("startup failed")) if fail_creation else AsyncMock(return_value=SimpleNamespace(start=AsyncMock(side_effect=RuntimeError("startup failed")))) + with patch.object(project.McpApplicationFactory, "create", create), patch.object(project.CODE_MODE, "dispose", new_callable=AsyncMock) as dispose: + try: + await project.main() + except RuntimeError as exc: + assert str(exc) == "startup failed" + else: + raise AssertionError("expected startup failure") + dispose.assert_awaited_once() + print("CLEANUP_OK") + + +if __name__ == "__main__": + action = sys.argv[1] + if action == "cli": + cli(sys.argv[2:]) + elif action == "cleanup": + asyncio.run(cleanup()) + else: + asyncio.run(behavior(action)) diff --git a/tests/test_project_templates.py b/tests/test_project_templates.py new file mode 100644 index 0000000..35be728 --- /dev/null +++ b/tests/test_project_templates.py @@ -0,0 +1,231 @@ +"""Issue #76: real generated projects, CLI lifecycle, and distributable templates.""" +from __future__ import annotations + +import io +import importlib.util +import json +import os +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +import shutil +import subprocess +import sys +import sysconfig +import tarfile +import zipfile + +import pytest + +from nitrostack.cli.main import OFFICIAL_TEMPLATES, _prompt_template, _resolve_template + +ROOT = Path(__file__).resolve().parents[1] +PROBE = ROOT / "tests" / "support" / "project_templates_probe.py" +TEMPLATES = [("python-enterprise-search", "enterprise-search"), ("python-code-mode", "code-mode")] + + +def run(args, cwd, *, python=None, installed=False): + env = dict(os.environ, NITROSTACK_SKIP_UV_LOCK="1", PYTHONIOENCODING="utf-8") + env["PYTHONPATH"] = "" if installed else str(ROOT) + for key in ("NITRO_MCP_PROTOCOL_VERSION", "MCP_STATELESS", "MCP_TRANSPORT_TYPE"): + env.pop(key, None) + result = subprocess.run([str(python or sys.executable), str(PROBE), *args], cwd=cwd, env=env, + input="", text=True, encoding="utf-8", capture_output=True, timeout=45) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout + + +@pytest.mark.parametrize("choice,expected", [("4", "python-enterprise-search"), ("5", "python-code-mode"), ("python-enterprise-search", "python-enterprise-search"), ("python-code-mode", "python-code-mode")]) +def test_new_template_prompt_choices(choice, expected, monkeypatch): + monkeypatch.setattr(sys, "stdin", io.StringIO(choice + "\n")) + assert _prompt_template() == expected + assert OFFICIAL_TEMPLATES[expected] in {"enterprise-search", "code-mode"} + + +@pytest.mark.parametrize("alias", ["enterprise-search", "code-mode"]) +def test_unprefixed_template_aliases_are_not_accepted(alias): + with pytest.raises(SystemExit): + _resolve_template(alias) + + +def test_new_templates_in_cli_help(tmp_path): + output = run(["cli", "init", "--help"], tmp_path) + assert all(name in output for name, _ in TEMPLATES) + + +@pytest.mark.parametrize("name,kind", TEMPLATES) +def test_generated_project_lifecycle(name, kind, tmp_path): + output = run(["cli", "init", "demo", "--template", name, "--skip-install"], tmp_path) + assert "Preview widgets" not in output and "openNow" not in output + project = tmp_path / "demo" + for filename in ("pyproject.toml", "requirements.txt", ".python-version", "uv.toml", "README.md", ".env.example", ".env", ".gitignore", "main.py", "app_module.py"): + assert (project / filename).is_file(), filename + assert (project / ".python-version").read_text().strip() == "3.12" + assert "No issues found" in run(["cli", "validate"], project) + run([kind], project) + if kind == "code-mode": + run(["cleanup"], project) + run(["cli", "upgrade", "--version", "0.3.2"], project) + assert "No issues found" in run(["cli", "validate"], project) + requirements = (project / "requirements.txt").read_text() + assert "nitrostack==0.3.2" in requirements + if kind == "code-mode": + assert "quickjs>=1.19.4" in requirements + assert "quickjs>=1.19.4" in (project / "pyproject.toml").read_text() + run(["cli", "pack"], project) + [wheel] = (project / "dist").glob("*.whl") + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + assert "main.py" in names and "app_module.py" in names + assert "modules/inventory.py" in names + assert not any(Path(name).name == ".env" for name in names) + if kind == "code-mode": + assert "scripts/sample-batch.js" in names + metadata = archive.read(next(name for name in names if name.endswith(".dist-info/METADATA"))).decode() + assert "Requires-Dist: nitrostack" in metadata + if kind == "code-mode": + assert "quickjs>=1.19.4" in metadata.replace(" ", "") + + +@pytest.mark.parametrize("name,kind", TEMPLATES) +def test_generated_entrypoint_stdio_roundtrip_and_shutdown(name, kind, tmp_path): + run(["cli", "init", "wire-demo", "--template", name, "--skip-install"], tmp_path) + project = tmp_path / "wire-demo" + env = dict(os.environ, PYTHONPATH=str(ROOT), MCP_TRANSPORT_TYPE="stdio", + NITRO_MCP_PROTOCOL_VERSION="auto", PYTHONIOENCODING="utf-8") + env.pop("MCP_STATELESS", None) + process = subprocess.Popen([sys.executable, "main.py"], cwd=project, env=env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding="utf-8") + reader = ThreadPoolExecutor(max_workers=1) + meta = { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "template-wire-test", "version": "1"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + + def rpc(request_id, method, params): + process.stdin.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": method, + "params": {**params, "_meta": meta}}) + "\n") + process.stdin.flush() + line = reader.submit(process.stdout.readline).result(timeout=15) + response = json.loads(line) + assert response["id"] == request_id + assert "error" not in response, response + return response["result"] + + try: + listed = rpc(1, "tools/list", {}) + names = {tool["name"] for tool in listed["tools"]} + if kind == "enterprise-search": + assert "search_tools" in names and "finance_list_invoices" not in names + result = rpc(2, "tools/call", {"name": "call_tool", "arguments": { + "name": "finance_list_invoices", "arguments": {"overdue_only": True}, + }}) + assert json.loads(result["content"][0]["text"])["invoices"][0]["id"] == "INV-001" + else: + assert names == {"search", "get_schema", "execute"} + if sys.platform == "win32" and importlib.util.find_spec("quickjs") is None: + result = rpc(2, "tools/call", {"name": "get_schema", "arguments": {"name": "inventory_check_stock"}}) + data = json.loads(result["content"][0]["text"]) + assert data["schemas"]["inventory_check_stock"]["inputSchema"]["required"] == ["sku"] + else: + result = rpc(2, "tools/call", {"name": "execute", "arguments": { + "code": (project / "scripts" / "sample-batch.js").read_text(), + }}) + data = json.loads(result["content"][0]["text"]) + assert data["success"] is True and data["value"]["total_units"] == 18 + process.stdin.close() + assert process.wait(timeout=15) == 0, process.stderr.read() + assert process.stdout.read() == "" + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + reader.shutdown() + for stream in (process.stdin, process.stdout, process.stderr): + stream.close() + + +@pytest.fixture(scope="module") +def sdk_distribution(tmp_path_factory): + base = tmp_path_factory.mktemp("sdk-templates") + source = base / "source" + source.mkdir() + shutil.copytree(ROOT / "nitrostack", source / "nitrostack", ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + for filename in ("pyproject.toml", "README.md", "LICENSE"): + if (ROOT / filename).is_file(): + shutil.copy2(ROOT / filename, source / filename) + dist = base / "dist" + dist.mkdir() + code = "from setuptools.build_meta import build_wheel, build_sdist; import sys; dest = sys.argv[1]; build_wheel(dest); build_sdist(dest)" + result = subprocess.run([sys.executable, "-c", code, str(dist)], cwd=source, capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stdout + result.stderr + return base, next(dist.glob("*.whl")), next(dist.glob("*.tar.gz")) + + +def test_sdk_wheel_and_sdist_contain_complete_templates(sdk_distribution): + _, wheel, sdist = sdk_distribution + required = {f"nitrostack/templates/{kind}/{file}" for _, kind in TEMPLATES + for file in (".python-version", ".env.example", ".gitignore", "README.md", "uv.toml", "requirements.txt", "pyproject.toml", "modules/inventory.py")} + required.add("nitrostack/templates/code-mode/scripts/sample-batch.js") + with zipfile.ZipFile(wheel) as archive: + assert required <= set(archive.namelist()) + with tarfile.open(sdist) as archive: + members = {name.split("/", 1)[1] for name in archive.getnames() if "/" in name} + assert required <= members + + +def test_installed_sdk_wheel_scaffolds_both_templates(sdk_distribution): + base, wheel, _ = sdk_distribution + envdir = base / "installed" + # Use the base interpreter: managed Python 3.10 venvs can otherwise write + # another venv's bin directory as their standard-library home. + base_python = Path(sys.base_prefix) / ("python.exe" if os.name == "nt" else "bin/python3") + result = subprocess.run([str(base_python), "-m", "venv", "--system-site-packages", str(envdir)], + capture_output=True, text=True, timeout=45) + assert result.returncode == 0, result.stdout + result.stderr + python = envdir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + env = dict(os.environ, PYTHONPATH="", PIP_DISABLE_PIP_VERSION_CHECK="1") + result = subprocess.run([str(python), "-m", "pip", "install", "--no-index", "--no-deps", "--ignore-installed", str(wheel)], env=env, cwd=base, capture_output=True, text=True, timeout=45) + assert result.returncode == 0, result.stdout + result.stderr + # A venv created from another venv inherits the base interpreter's site, + # not the parent's installed dependencies. Share only that dependency path; + # the SDK wheel itself must resolve from the new environment below. + site = subprocess.run([str(python), "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])"], + env=env, cwd=base, text=True, capture_output=True, check=True, timeout=10) + (Path(site.stdout.strip()) / "test_dependencies.pth").write_text( + f"import site; site.addsitedir({sysconfig.get_paths()['purelib']!r})\n" + ) + check = subprocess.run([str(python), "-c", "import nitrostack; print(nitrostack.__file__)"], env=env, cwd=base, text=True, capture_output=True, timeout=10) + assert str(envdir).casefold() in check.stdout.casefold(), check.stdout + check.stderr + for name, _ in TEMPLATES: + target = base / name + run(["cli", "init", name, "--template", name, "--skip-install"], base, python=python, installed=True) + assert "No issues found" in run(["cli", "validate"], target, python=python, installed=True) + + +def test_upgrade_preserves_vendored_source_assignment(): + from nitrostack.cli.upgrade import find_current_spec, replace_nitrostack_spec + from nitrostack.cli._shared import loads_toml + for key in ("nitrostack", '\"nitrostack\"'): + source = f'[tool.uv.sources]\n{key} = {{path = "vendor/nitrostack"}}\n' + assert find_current_spec(source) is None + assert replace_nitrostack_spec(source, "nitrostack==0.3.2") == (source, 0) + project = '[project]\nname="demo"\ndependencies=["nitrostack", "quickjs>=1.19.4"]\n' + source + updated, count = replace_nitrostack_spec(project, "nitrostack==0.3.2") + parsed = loads_toml(updated) + assert count == 1 + assert parsed["tool"]["uv"]["sources"]["nitrostack"] == {"path": "vendor/nitrostack"} + assert parsed["project"]["dependencies"] == ["nitrostack==0.3.2", "quickjs>=1.19.4"] + + +def test_validation_checks_application_but_not_vendored_sdk(tmp_path): + from nitrostack.cli.validators import validate_project + (tmp_path / "pyproject.toml").write_text('[project]\nname="demo"\ndependencies=["nitrostack"]\n') + sdk = tmp_path / "vendor" / "nitrostack" + sdk.mkdir(parents=True) + (sdk / "helper.py").write_text('from nitrostack import mcp_app\ndef helper():\n @mcp_app(module=None)\n class DynamicApp: pass\n') + (tmp_path / "bad_application.py").write_text('from nitrostack import module\n@module(name="broken", imports=["not-a-class"])\nclass InvalidRoot: pass\n') + issues = validate_project(str(tmp_path)) + assert any(issue.severity == "error" and issue.path == "bad_application.py" for issue in issues) + assert not any("vendor" in (issue.path or "") for issue in issues) diff --git a/uv.lock b/uv.lock index e906362..ec3442a 100644 --- a/uv.lock +++ b/uv.lock @@ -601,6 +601,8 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-timeout" }, + { name = "quickjs" }, + { name = "setuptools" }, { name = "tox" }, ] @@ -617,6 +619,8 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.3.0" }, { name = "quickjs", marker = "extra == 'code-mode'", specifier = ">=1.19.4" }, + { name = "quickjs", marker = "extra == 'dev'", specifier = ">=1.19.4" }, + { name = "setuptools", marker = "extra == 'dev'", specifier = ">=70.1" }, { name = "starlette", specifier = ">=0.30.0" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" }, { name = "tox", marker = "extra == 'dev'", specifier = ">=4.0.0" }, @@ -1235,6 +1239,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, ] +[[package]] +name = "setuptools" +version = "84.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/b3/c19426f783614d625699a4b247faf628ea9ab1db228674491767b417f9e1/setuptools-84.2.1.tar.gz", hash = "sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc", size = 1230289, upload-time = "2026-10-20T19:01:37.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/55/6dc6d6bb953bdfc52519a0059592c52b6e3b8ad51cf8256dffceb55b0610/setuptools-84.2.1-py3-none-any.whl", hash = "sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2", size = 876263, upload-time = "2026-10-20T19:01:35.678Z" }, +] + [[package]] name = "sse-starlette" version = "3.4.11"