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
11 changes: 7 additions & 4 deletions .claude/skills/tina4-developer-python/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,11 @@ the developer can correct.
### 1. Keep the main session free — delegate to a worker
When the developer gives an instruction, don't do the work inline. **Allocate it to a plan, then
spawn a separate worker to execute it**, so the main session is always free for the next input.
Tina4 **hot-reloads on save** (DevReload), so as the worker edits routes, models, and templates the
developer watches the interface change **live in the browser** — keeping the main session open is
Under `tina4 serve` the file watcher reloads on save: templates and static files refresh the browser,
and an edited or NEW file under `src/` (routes, models) is re-imported through `POST /__dev/api/reload`
with no restart. A bare `python app.py` has no watcher, so a route or model file added there needs a
restart. The MCP `route_list` tool reads the live route registry, so it reflects a new file once the reload
has run. As the worker edits, the developer watches the interface change **live in the browser** — keeping the main session open is
what lets them observe and steer while the work happens. The main agent scopes, dispatches, and
reports; workers build and update the plan. When a worker finishes an item, surface it to the
developer. Whoever builds updates `plan/<feature>.md` in the **same turn** they claim progress:
Expand Down Expand Up @@ -415,13 +418,13 @@ whenever the dev server is running (`tina4 serve` with `TINA4_DEBUG=true`):

- **`api_search("render template")`** — ranked search across framework + your own code; returns fqn, signature, file:line. Run it BEFORE assuming a method exists.
- **`api_class("Frond")`** — every method on a class, with signatures. A bare name (`Frond`), an import path, or the full fqn all resolve.
- **`api_method("Frond", "add_test")`** — exact signature, params, return type, file and line for one method.
- **`api_method`** with arguments `class` and `name` (e.g. `class="Frond"`, `name="add_test"`) — exact signature, a structured `params` list (`name`, `type`, `required`, `default`), `return` type, file and line for one method. Omit an argument and it answers `missing required argument 'name' (api_method takes class, name)`.
- **`code_search("where is the auth token issued?")`** — fuzzy/semantic full-text search over **THIS project's own source + docs** (the native `Context` FTS5 index — zero-dep, kept live on every file save). Ranks the file that *defines* a symbol above tests that merely mention it. The in-repo, semantic counterpart to `api_*`.

```
api_search("queue consume") -> finds Queue.consume and its signature
api_class("Database") -> every method on Database, with signatures
api_method("Frond", "add_test") -> add_test(name, fn)
api_method(class="Frond", name="add_test") -> add_test(name, fn)
code_search("send an email") -> the routes/services in YOUR app that already do it
```

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Tina4 Python — Agent Instructions

v3.13.146. 140 cataloged features, zero dependencies. Python 3.12+.
v3.13.147. 140 cataloged features, zero dependencies. Python 3.12+.

## AI Skills

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ https://tina4.com/python/36-releases
This file records framework-specific changes. The release notes above remain the
authority for shipped versions.

## 3.13.147 — 2026-10-05
### Dev MCP tools (fixes tina4-php#271)
- `database_columns` reads schema metadata, so an empty table returns its columns; a missing table returns a clear `table not found` error.
- Tool argument validation returns an actionable `missing/unknown argument` error instead of a raw language error or 500; `api_method` now populates `params` and `return`.
- `route_list` entries include each route's `middleware`, so a middleware-guarded route is distinguishable from an open one.
- A new route file added while the server runs surfaces a signal instead of silence.
- Developer skill corrected: per-language hot-reload reality, and `api_method` shown with argument names.

## 3.13.146 — 2026-10-04
### AI skills
- Every skill now opens with a content list (table of contents) and a degrees-of-freedom legend (what is inviolable, what is a default, what is judgement).
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The full discipline lives in `.claude/skills/tina4-maintainer/SKILL.md`; this bl

# Tina4 Python

Version 3.13.146 - Lightweight Python web framework. See https://tina4.com for full documentation.
Version 3.13.147 - Lightweight Python web framework. See https://tina4.com for full documentation.

## Build & Test

Expand Down Expand Up @@ -939,7 +939,7 @@ uv run tina4python test # Discovers @tests in src/**/*.py
- SSE/Streaming via `response.stream()` — Server-Sent Events support for real-time data push. Pass an async generator; framework handles chunked transfer encoding, `text/event-stream` content type, and connection keep-alive
- MCP server (`tina4_python.mcp`): built-in dev tools auto-start when MCP is a capability of the deployment. Developer API: `McpServer`, `@mcp_tool`, `@mcp_resource`. JSON-RPC 2.0 over SSE. **Security is a two-layer gate (v3.13.40):** `is_enabled()` is a pure capability gate (explicit `TINA4_MCP` wins, else `TINA4_DEBUG`; host-independent), and `is_request_allowed(remote_ip, has_valid_token)` authorises each request on the RAW socket peer (`request.remote_ip`, never X-Forwarded-For): loopback always; a remote caller needs `TINA4_MCP_REMOTE=true` AND a token matching `TINA4_MCP_TOKEN` (never `TINA4_API_KEY`, ADR-0078; sent as Authorization Bearer / X-MCP-Token; no configured token means remote is always denied). Every `/__dev` request (reads too), the MCP endpoints and the `/__dev_reload` socket also require a loopback Host (`localhost`, `127.0.0.1`, `[::1]` or `TINA4_HOST`) and refuse `Sec-Fetch-Site: same-site`/`cross-site` (ADR-0078). `/health` omits `version` outside debug. All MCP surfaces (REST shim, JSON-RPC, SSE) 404 a disallowed caller. `database_query` is SELECT/WITH-only and rejects stacked statements; the file tools are sandboxed to the project root. `is_localhost()` is informational only, not the gate
- Tests: 6,088 passing, 0 failures, **0 skipped** — measured 2026-09-17 on the lab (Ubuntu 24.04.4 LTS x86_64, Python 3.13.13, live services, `TINA4_REQUIRE_SERVICES=1`, 604s, run as root). **Firebird is NOT excluded.** The lab provisions a live Firebird 5 (`firebirdsql/firebird:5` on :3050, `TINA4_TEST_FIREBIRD_URL`) and those tests run. What IS deliberate is Firebird's absence from the `TINA4_REQUIRE_SERVICES` keyword gate in `tests/conftest.py`: GitHub CI does not provision Firebird, so a Firebird skip has to stay green *there*. Those are two different things and this line used to conflate them into "excluded by design", which read as "the Firebird tests do not run" — they do. **Nothing skips any more.** Reaching 0 skips now also needs the graph engines (Neo4j, Memgraph, ArangoDB, Ultipa, wired via `TINA4_TEST_NEO4J_URL`/`_MEMGRAPH_URL`/`_ARANGO_URL`/`_ULTIPA_URL` + the `graph` extra) and the lab Keycloak OIDC realm (`TINA4_REQUIRE_OIDC=1`), both added after the August baseline; without them `test_graph.py` (Feature 139) and the real OIDC contract test skip. The last skip was `tests/test_session_backend_failure.py` `[needs:no-dac-override]`: the suite runs as root, root holds `CAP_DAC_OVERRIDE`, so a write went straight through a 0400 file and no real `EACCES` was reachable — and as root the second `save()` returned `True`, so that skip was hiding a FAILING assertion, not merely an unrunnable one. The test now stops being root for the length of the failing write (`os.seteuid` to `nobody`; the saved uid stays 0, so a `finally` always restores it) and the kernel raises the genuine errno-13 the test asserts. Two parts of that are load-bearing: the log directory is handed to the same uid, because dropping the uid otherwise denies `Log.error` as well and the EACCES under test goes unrecorded (`_LogWriter.write` swallows `OSError` unless `TINA4_LOG_STRICT`); and the fixture root is its own 0755 `mkdtemp` rather than `tmp_path`, whose 0700 root-owned parents make every denial a directory traversal, which would pass for the wrong reason. Re-measure with `.venv/bin/python -m pytest tests/ -q` and quote the summary line, never the exit code
- Version: 3.13.146
- Version: 3.13.147

## Links

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "tina4-python"
version = "3.13.146"
version = "3.13.147"
description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
authors = [
{name = "Andre van Zuydam", email = "[email protected]"}
Expand Down
175 changes: 175 additions & 0 deletions tests/test_mcp_dev_tools_271.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Copyright (c) 2026 Code Infinity
# SPDX-License-Identifier: MPL-2.0
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.

"""
Regression tests for tina4-php#271 (dev MCP tool rough edges), Python reference.

No mocks: a real McpServer with the real dev tools, driven through the real
JSON-RPC ``tools/call`` path (``handle_message``), against a real SQLite
database that includes an EMPTY table and real registered routes.

P1 database_columns reports schema columns for an empty table; missing table errors.
P2 tool arguments are validated against the input schema before the call.
P3 route_list carries each route's middleware names.
P4 a route file added while running registers and shows up in route_list.
"""
import json
import os
import sys
import textwrap

import pytest


class AuditMiddleware:
"""Real middleware class attached to a noauth route in the tests."""

@staticmethod
def before_audit(request, response):
return request, response


@pytest.fixture
def mcp(tmp_path, monkeypatch):
import tina4_python.core.router as router_mod
import tina4_python.orm.model as orm_model
from tina4_python.core.router import get, noauth, middleware
from tina4_python.database import Database
from tina4_python.mcp import McpServer
from tina4_python.mcp.tools import register_dev_tools
from tina4_python.orm import bind_database

old_cwd = os.getcwd()
old_database = orm_model._database
old_databases = dict(orm_model._databases)
old_routes = list(router_mod._routes)
monkeypatch.setenv("TINA4_DEBUG", "true")
monkeypatch.setenv("TINA4_MCP", "true")
monkeypatch.delenv("TINA4_DATABASE_URL", raising=False)
monkeypatch.syspath_prepend(str(tmp_path))
(tmp_path / "src" / "routes").mkdir(parents=True)
(tmp_path / "src" / "__init__.py").write_text("")
(tmp_path / "src" / "routes" / "__init__.py").write_text("")
os.chdir(tmp_path)

db = Database(f"sqlite:///{tmp_path / 'm271.db'}")
bind_database(db)
db.execute("CREATE TABLE empty_widgets (id INTEGER PRIMARY KEY, label TEXT NOT NULL, qty INTEGER)")
db.commit()

@noauth()
@middleware(AuditMiddleware)
@get("/m271/guarded")
async def _guarded(request, response):
return response({"ok": True})

@get("/m271/plain")
async def _plain(request, response):
return response({"ok": True})

server = McpServer("/__dev/mcp", name="m271")
register_dev_tools(server)
request_id = [0]

def call(tool, arguments=None):
request_id[0] += 1
raw = server.handle_message({
"jsonrpc": "2.0", "id": request_id[0], "method": "tools/call",
"params": {"name": tool, "arguments": arguments if arguments is not None else {}},
})
message = json.loads(raw)
assert "error" not in message, f"{tool} escaped as a JSON-RPC error: {message}"
return json.loads(message["result"]["content"][0]["text"])

call.server_tools = lambda: server._handle_tools_list({})["tools"]
yield call

os.chdir(old_cwd)
orm_model._database = old_database
orm_model._databases.clear()
orm_model._databases.update(old_databases)
router_mod._routes[:] = old_routes
for module_name in list(sys.modules):
if module_name == "src" or module_name.startswith("src."):
del sys.modules[module_name]


# ── P1 ──────────────────────────────────────────────────────────

def test_database_columns_reports_columns_of_an_empty_table(mcp):
columns = mcp("database_columns", {"table": "empty_widgets"})
by_name = {column["name"]: column for column in columns}
assert list(by_name) == ["id", "label", "qty"]
assert by_name["label"]["type"] == "TEXT" and by_name["label"]["nullable"] is False
assert by_name["id"]["primary_key"] is True


def test_database_columns_missing_table_is_a_clear_error_not_empty(mcp):
result = mcp("database_columns", {"table": "no_such_table"})
assert isinstance(result, dict) and "no_such_table" in result["error"]


# ── P2 ──────────────────────────────────────────────────────────

def test_missing_required_argument_is_actionable(mcp):
assert mcp("api_method", {"class": "Database"}) == {
"error": "missing required argument 'name' (api_method takes class, name)"}
assert mcp("database_columns", {}) == {
"error": "missing required argument 'table' (database_columns takes table)"}


def test_unknown_argument_is_rejected_with_same_shape(mcp):
assert mcp("api_method", {"class": "Database", "name": "get_columns", "method": "x"}) == {
"error": "unknown argument 'method' (api_method takes class, name)"}
assert mcp("route_list", {"bogus": 1}) == {
"error": "unknown argument 'bogus' (route_list takes no arguments)"}


def test_every_tool_with_required_args_rejects_an_empty_call_actionably(mcp):
listing = mcp.server_tools()
with_required = [tool for tool in listing if tool["inputSchema"].get("required")]
assert len(with_required) > 10
for tool in with_required:
first = tool["inputSchema"]["required"][0]
result = mcp(tool["name"], {})
assert result["error"].startswith(f"missing required argument '{first}' ({tool['name']} takes "), tool["name"]


def test_api_method_populates_params_and_return(mcp):
spec = mcp("api_method", {"class": "Database", "name": "get_columns"})
assert spec["params"] == [{"name": "table", "type": "str", "required": True, "default": None}]
assert spec["return"] == "list[dict]"


def test_api_method_unknown_method_still_errors(mcp):
assert "method not found" in mcp("api_method", {"class": "Database", "name": "nope"})["error"]


# ── P3 ──────────────────────────────────────────────────────────

def test_route_list_reports_middleware_names(mcp):
routes = {route["path"]: route for route in mcp("route_list")}
guarded, plain = routes["/m271/guarded"], routes["/m271/plain"]
assert guarded["middleware"] == ["AuditMiddleware"]
assert guarded["auth_required"] is False and guarded["method"] == "GET"
assert plain["middleware"] == []


# ── P4 ──────────────────────────────────────────────────────────

def test_route_file_added_at_runtime_shows_in_route_list(mcp, tmp_path):
from tina4_python.core import server as server_module
server_module._auto_discover("src")
assert "/m271/late" not in [route["path"] for route in mcp("route_list")]
(tmp_path / "src" / "routes" / "late.py").write_text(textwrap.dedent('''
from tina4_python.core.router import get

@get("/m271/late")
async def late(request, response):
return response({"ok": True})
'''))
server_module._auto_discover("src") # what POST /__dev/api/reload runs
assert "/m271/late" in [route["path"] for route in mcp("route_list")]
2 changes: 1 addition & 1 deletion tests/test_mcp_dev_tools_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ async def _echo(request, response):
"plan_flesh": {"name": "fixture-plan.md", "prompt": "probe"},
"api_search": {"query": "ORM", "k": 3},
"api_class": {"name": "ORM"},
"api_method": {"class_": "Database", "name": "fetch"},
"api_method": {"class": "Database", "name": "fetch"},
"code_search": {"query": "route", "k": 3},
}

Expand Down
Loading
Loading