Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
27 changes: 24 additions & 3 deletions nitrostack/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand Down
28 changes: 22 additions & 6 deletions nitrostack/cli/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions nitrostack/cli/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
3 changes: 3 additions & 0 deletions nitrostack/templates/code-mode/.env.example
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions nitrostack/templates/code-mode/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.env
.venv/
__pycache__/
*.pyc
*.egg-info/
build/
dist/
1 change: 1 addition & 0 deletions nitrostack/templates/code-mode/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
83 changes: 83 additions & 0 deletions nitrostack/templates/code-mode/README.md
Original file line number Diff line number Diff line change
@@ -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 <released-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]`.
14 changes: 14 additions & 0 deletions nitrostack/templates/code-mode/app_module.py
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions nitrostack/templates/code-mode/main.py
Original file line number Diff line number Diff line change
@@ -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())
Empty file.
15 changes: 15 additions & 0 deletions nitrostack/templates/code-mode/modules/data.py
Original file line number Diff line number Diff line change
@@ -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)}
24 changes: 24 additions & 0 deletions nitrostack/templates/code-mode/modules/finance.py
Original file line number Diff line number Diff line change
@@ -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"]]}
23 changes: 23 additions & 0 deletions nitrostack/templates/code-mode/modules/inventory.py
Original file line number Diff line number Diff line change
@@ -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)}
9 changes: 9 additions & 0 deletions nitrostack/templates/code-mode/modules/models.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions nitrostack/templates/code-mode/modules/support.py
Original file line number Diff line number Diff line change
@@ -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()]}
Loading
Loading