tina4-python 3.10.30__tar.gz → 3.10.32__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tina4_python-3.10.30 → tina4_python-3.10.32}/PKG-INFO +1 -1
- {tina4_python-3.10.30 → tina4_python-3.10.32}/pyproject.toml +1 -1
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/frond/engine.py +38 -2
- tina4_python-3.10.32/tina4_python/mcp/__init__.py +361 -0
- tina4_python-3.10.32/tina4_python/mcp/protocol.py +75 -0
- tina4_python-3.10.32/tina4_python/mcp/tools.py +348 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/.gitignore +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/README.md +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/Testing.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/core/events.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/core/request.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/core/response.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/core/router.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/core/server.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/dev_reload.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.10.30 → tina4_python-3.10.32}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -590,6 +590,39 @@ def _eval_expr(expr: str, context: dict):
|
|
|
590
590
|
if _find_outside_quotes(expr, op) >= 0:
|
|
591
591
|
return _eval_comparison(expr, context)
|
|
592
592
|
|
|
593
|
+
# Arithmetic operators: +, -, *, /, //, %, ** (lowest to highest precedence)
|
|
594
|
+
# Check for +/- first (lower precedence), then *//, then %, then **
|
|
595
|
+
for op in (" + ", " - ", " * ", " // ", " / ", " % ", " ** "):
|
|
596
|
+
pos = _find_outside_quotes(expr, op)
|
|
597
|
+
if pos >= 0:
|
|
598
|
+
left = expr[:pos].strip()
|
|
599
|
+
right = expr[pos + len(op):].strip()
|
|
600
|
+
l_val = _eval_expr(left, context)
|
|
601
|
+
r_val = _eval_expr(right, context)
|
|
602
|
+
try:
|
|
603
|
+
l_num = float(l_val) if l_val is not None else 0
|
|
604
|
+
r_num = float(r_val) if r_val is not None else 0
|
|
605
|
+
# Preserve int type when both operands are int-like
|
|
606
|
+
if l_num == int(l_num) and r_num == int(r_num) and op.strip() not in ("/",):
|
|
607
|
+
l_num, r_num = int(l_num), int(r_num)
|
|
608
|
+
op_s = op.strip()
|
|
609
|
+
if op_s == "+":
|
|
610
|
+
return l_num + r_num
|
|
611
|
+
elif op_s == "-":
|
|
612
|
+
return l_num - r_num
|
|
613
|
+
elif op_s == "*":
|
|
614
|
+
return l_num * r_num
|
|
615
|
+
elif op_s == "//":
|
|
616
|
+
return l_num // r_num if r_num != 0 else 0
|
|
617
|
+
elif op_s == "/":
|
|
618
|
+
return l_num / r_num if r_num != 0 else 0
|
|
619
|
+
elif op_s == "%":
|
|
620
|
+
return l_num % r_num if r_num != 0 else 0
|
|
621
|
+
elif op_s == "**":
|
|
622
|
+
return l_num ** r_num
|
|
623
|
+
except (ValueError, TypeError):
|
|
624
|
+
return None
|
|
625
|
+
|
|
593
626
|
# Function call: name("arg1", "arg2") or obj.method("arg1")
|
|
594
627
|
fn_match = _FUNC_CALL_RE.match(expr)
|
|
595
628
|
if fn_match:
|
|
@@ -1582,12 +1615,15 @@ class Frond:
|
|
|
1582
1615
|
return "".join(output), i
|
|
1583
1616
|
|
|
1584
1617
|
def _handle_set(self, content: str, context: dict):
|
|
1585
|
-
"""Handle {% set name = expr %}.
|
|
1618
|
+
"""Handle {% set name = expr %}.
|
|
1619
|
+
|
|
1620
|
+
Uses _eval_var_raw so filter pipes work (e.g. a.dr|default(0)).
|
|
1621
|
+
"""
|
|
1586
1622
|
m = _SET_RE.match(content)
|
|
1587
1623
|
if m:
|
|
1588
1624
|
name = m.group(1)
|
|
1589
1625
|
expr = m.group(2).strip()
|
|
1590
|
-
context[name] =
|
|
1626
|
+
context[name] = self._eval_var_raw(expr, context)
|
|
1591
1627
|
|
|
1592
1628
|
def _handle_include(self, content: str, context: dict) -> str:
|
|
1593
1629
|
"""Handle {% include "file.html" %} with optional 'with' and 'ignore missing'."""
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# Tina4 MCP Server — Model Context Protocol for AI tool integration.
|
|
2
|
+
"""
|
|
3
|
+
Built-in MCP server for dev tools + developer API for custom MCP servers.
|
|
4
|
+
|
|
5
|
+
Usage (developer):
|
|
6
|
+
|
|
7
|
+
from tina4_python.mcp import McpServer, mcp_tool, mcp_resource
|
|
8
|
+
|
|
9
|
+
mcp = McpServer("/my-mcp", name="My App Tools")
|
|
10
|
+
|
|
11
|
+
@mcp_tool("lookup_invoice", description="Find invoice by number")
|
|
12
|
+
def lookup_invoice(invoice_no: str):
|
|
13
|
+
return db.fetch_one("SELECT * FROM invoices WHERE invoice_no = ?", [invoice_no])
|
|
14
|
+
|
|
15
|
+
@mcp_resource("app://schema", description="Database schema")
|
|
16
|
+
def get_schema():
|
|
17
|
+
return db.get_tables()
|
|
18
|
+
|
|
19
|
+
Built-in dev tools auto-register when TINA4_DEBUG=true and running on localhost.
|
|
20
|
+
"""
|
|
21
|
+
import os
|
|
22
|
+
import json
|
|
23
|
+
import inspect
|
|
24
|
+
import socket
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .protocol import (
|
|
28
|
+
encode_response, encode_error, encode_notification,
|
|
29
|
+
decode_request,
|
|
30
|
+
PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND,
|
|
31
|
+
INVALID_PARAMS, INTERNAL_ERROR,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Type hint → JSON Schema type mapping (reuse Swagger pattern)
|
|
35
|
+
_TYPE_MAP = {
|
|
36
|
+
str: "string",
|
|
37
|
+
int: "integer",
|
|
38
|
+
float: "number",
|
|
39
|
+
bool: "boolean",
|
|
40
|
+
list: "array",
|
|
41
|
+
dict: "object",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _schema_from_signature(func) -> dict:
|
|
46
|
+
"""Extract JSON Schema input schema from function type hints."""
|
|
47
|
+
sig = inspect.signature(func)
|
|
48
|
+
properties = {}
|
|
49
|
+
required = []
|
|
50
|
+
|
|
51
|
+
for name, param in sig.parameters.items():
|
|
52
|
+
if name == "self":
|
|
53
|
+
continue
|
|
54
|
+
annotation = param.annotation
|
|
55
|
+
prop = {"type": _TYPE_MAP.get(annotation, "string")}
|
|
56
|
+
|
|
57
|
+
if param.default is inspect.Parameter.empty:
|
|
58
|
+
required.append(name)
|
|
59
|
+
else:
|
|
60
|
+
prop["default"] = param.default
|
|
61
|
+
|
|
62
|
+
properties[name] = prop
|
|
63
|
+
|
|
64
|
+
schema = {"type": "object", "properties": properties}
|
|
65
|
+
if required:
|
|
66
|
+
schema["required"] = required
|
|
67
|
+
return schema
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_localhost() -> bool:
|
|
71
|
+
"""Check if the server is running on localhost."""
|
|
72
|
+
host = os.environ.get("HOST_NAME", "localhost:7145").split(":")[0]
|
|
73
|
+
return host in ("localhost", "127.0.0.1", "0.0.0.0", "::1", "")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class McpServer:
|
|
77
|
+
"""MCP server that registers tools and resources on a given HTTP path.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
path: HTTP path to serve the MCP endpoint (e.g. "/my-mcp").
|
|
81
|
+
name: Human-readable server name.
|
|
82
|
+
version: Server version string.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
# Class-level registry of all MCP server instances
|
|
86
|
+
_instances: list = []
|
|
87
|
+
|
|
88
|
+
def __init__(self, path: str, name: str = "Tina4 MCP", version: str = "1.0.0"):
|
|
89
|
+
self.path = path.rstrip("/")
|
|
90
|
+
self.name = name
|
|
91
|
+
self.version = version
|
|
92
|
+
self._tools: dict[str, dict] = {}
|
|
93
|
+
self._resources: dict[str, dict] = {}
|
|
94
|
+
self._initialized = False
|
|
95
|
+
McpServer._instances.append(self)
|
|
96
|
+
|
|
97
|
+
def register_tool(self, name: str, handler, description: str = "", schema: dict | None = None):
|
|
98
|
+
"""Register a tool callable."""
|
|
99
|
+
if schema is None:
|
|
100
|
+
schema = _schema_from_signature(handler)
|
|
101
|
+
self._tools[name] = {
|
|
102
|
+
"name": name,
|
|
103
|
+
"description": description or (handler.__doc__ or "").strip(),
|
|
104
|
+
"inputSchema": schema,
|
|
105
|
+
"handler": handler,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
def register_resource(self, uri: str, handler, description: str = "", mime_type: str = "application/json"):
|
|
109
|
+
"""Register a resource URI."""
|
|
110
|
+
self._resources[uri] = {
|
|
111
|
+
"uri": uri,
|
|
112
|
+
"name": description or uri,
|
|
113
|
+
"description": description or (handler.__doc__ or "").strip(),
|
|
114
|
+
"mimeType": mime_type,
|
|
115
|
+
"handler": handler,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def handle_message(self, raw_data: str | dict) -> str:
|
|
119
|
+
"""Process an incoming JSON-RPC message and return the response."""
|
|
120
|
+
try:
|
|
121
|
+
method, params, request_id = decode_request(raw_data)
|
|
122
|
+
except ValueError as e:
|
|
123
|
+
return encode_error(None, PARSE_ERROR, str(e))
|
|
124
|
+
|
|
125
|
+
handler = {
|
|
126
|
+
"initialize": self._handle_initialize,
|
|
127
|
+
"notifications/initialized": self._handle_initialized,
|
|
128
|
+
"tools/list": self._handle_tools_list,
|
|
129
|
+
"tools/call": self._handle_tools_call,
|
|
130
|
+
"resources/list": self._handle_resources_list,
|
|
131
|
+
"resources/read": self._handle_resources_read,
|
|
132
|
+
"ping": self._handle_ping,
|
|
133
|
+
}.get(method)
|
|
134
|
+
|
|
135
|
+
if handler is None:
|
|
136
|
+
return encode_error(request_id, METHOD_NOT_FOUND, f"Method not found: {method}")
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
result = handler(params)
|
|
140
|
+
if request_id is None:
|
|
141
|
+
return "" # Notification — no response
|
|
142
|
+
return encode_response(request_id, result)
|
|
143
|
+
except Exception as e:
|
|
144
|
+
return encode_error(request_id, INTERNAL_ERROR, str(e))
|
|
145
|
+
|
|
146
|
+
def _handle_initialize(self, params: dict) -> dict:
|
|
147
|
+
"""Handle initialize request — return server capabilities."""
|
|
148
|
+
self._initialized = True
|
|
149
|
+
return {
|
|
150
|
+
"protocolVersion": "2024-11-05",
|
|
151
|
+
"capabilities": {
|
|
152
|
+
"tools": {"listChanged": False},
|
|
153
|
+
"resources": {"subscribe": False, "listChanged": False},
|
|
154
|
+
},
|
|
155
|
+
"serverInfo": {
|
|
156
|
+
"name": self.name,
|
|
157
|
+
"version": self.version,
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
def _handle_initialized(self, params: dict):
|
|
162
|
+
"""Handle initialized notification."""
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
def _handle_ping(self, params: dict) -> dict:
|
|
166
|
+
return {}
|
|
167
|
+
|
|
168
|
+
def _handle_tools_list(self, params: dict) -> dict:
|
|
169
|
+
"""Return list of registered tools."""
|
|
170
|
+
tools = []
|
|
171
|
+
for t in self._tools.values():
|
|
172
|
+
tools.append({
|
|
173
|
+
"name": t["name"],
|
|
174
|
+
"description": t["description"],
|
|
175
|
+
"inputSchema": t["inputSchema"],
|
|
176
|
+
})
|
|
177
|
+
return {"tools": tools}
|
|
178
|
+
|
|
179
|
+
def _handle_tools_call(self, params: dict) -> dict:
|
|
180
|
+
"""Invoke a tool by name."""
|
|
181
|
+
tool_name = params.get("name")
|
|
182
|
+
if not tool_name:
|
|
183
|
+
raise ValueError("Missing tool name")
|
|
184
|
+
|
|
185
|
+
tool = self._tools.get(tool_name)
|
|
186
|
+
if not tool:
|
|
187
|
+
raise ValueError(f"Unknown tool: {tool_name}")
|
|
188
|
+
|
|
189
|
+
arguments = params.get("arguments", {})
|
|
190
|
+
handler = tool["handler"]
|
|
191
|
+
|
|
192
|
+
# Call the handler with the provided arguments
|
|
193
|
+
result = handler(**arguments)
|
|
194
|
+
|
|
195
|
+
# Format result as MCP content
|
|
196
|
+
if isinstance(result, str):
|
|
197
|
+
content = [{"type": "text", "text": result}]
|
|
198
|
+
elif isinstance(result, dict) or isinstance(result, list):
|
|
199
|
+
content = [{"type": "text", "text": json.dumps(result, default=str, indent=2)}]
|
|
200
|
+
else:
|
|
201
|
+
content = [{"type": "text", "text": str(result)}]
|
|
202
|
+
|
|
203
|
+
return {"content": content}
|
|
204
|
+
|
|
205
|
+
def _handle_resources_list(self, params: dict) -> dict:
|
|
206
|
+
"""Return list of registered resources."""
|
|
207
|
+
resources = []
|
|
208
|
+
for r in self._resources.values():
|
|
209
|
+
resources.append({
|
|
210
|
+
"uri": r["uri"],
|
|
211
|
+
"name": r["name"],
|
|
212
|
+
"description": r["description"],
|
|
213
|
+
"mimeType": r["mimeType"],
|
|
214
|
+
})
|
|
215
|
+
return {"resources": resources}
|
|
216
|
+
|
|
217
|
+
def _handle_resources_read(self, params: dict) -> dict:
|
|
218
|
+
"""Read a resource by URI."""
|
|
219
|
+
uri = params.get("uri")
|
|
220
|
+
if not uri:
|
|
221
|
+
raise ValueError("Missing resource URI")
|
|
222
|
+
|
|
223
|
+
resource = self._resources.get(uri)
|
|
224
|
+
if not resource:
|
|
225
|
+
raise ValueError(f"Unknown resource: {uri}")
|
|
226
|
+
|
|
227
|
+
result = resource["handler"]()
|
|
228
|
+
|
|
229
|
+
if isinstance(result, str):
|
|
230
|
+
text = result
|
|
231
|
+
elif isinstance(result, (dict, list)):
|
|
232
|
+
text = json.dumps(result, default=str, indent=2)
|
|
233
|
+
else:
|
|
234
|
+
text = str(result)
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
"contents": [{
|
|
238
|
+
"uri": uri,
|
|
239
|
+
"mimeType": resource["mimeType"],
|
|
240
|
+
"text": text,
|
|
241
|
+
}]
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
def register_routes(self, router_module):
|
|
245
|
+
"""Register HTTP routes for this MCP server on the Tina4 router.
|
|
246
|
+
|
|
247
|
+
Registers:
|
|
248
|
+
POST {path}/message — JSON-RPC message endpoint
|
|
249
|
+
GET {path}/sse — SSE endpoint for streaming
|
|
250
|
+
"""
|
|
251
|
+
server = self
|
|
252
|
+
msg_path = f"{self.path}/message"
|
|
253
|
+
sse_path = f"{self.path}/sse"
|
|
254
|
+
|
|
255
|
+
@router_module.post(msg_path)
|
|
256
|
+
@router_module.noauth()
|
|
257
|
+
async def mcp_message(request, response):
|
|
258
|
+
body = request.body
|
|
259
|
+
if isinstance(body, dict):
|
|
260
|
+
raw = body
|
|
261
|
+
else:
|
|
262
|
+
raw = body if isinstance(body, str) else str(body)
|
|
263
|
+
result = server.handle_message(raw)
|
|
264
|
+
if not result:
|
|
265
|
+
return response("", 204)
|
|
266
|
+
return response(json.loads(result))
|
|
267
|
+
|
|
268
|
+
@router_module.get(sse_path)
|
|
269
|
+
@router_module.noauth()
|
|
270
|
+
async def mcp_sse(request, response):
|
|
271
|
+
# SSE endpoint — send initial endpoint message
|
|
272
|
+
endpoint_url = f"{request.url.rsplit('/sse', 1)[0]}/message"
|
|
273
|
+
sse_data = f"event: endpoint\ndata: {endpoint_url}\n\n"
|
|
274
|
+
from tina4_python.core.response import Response as Resp
|
|
275
|
+
r = Resp()
|
|
276
|
+
r.status_code = 200
|
|
277
|
+
r.content_type = "text/event-stream"
|
|
278
|
+
r.content = sse_data.encode()
|
|
279
|
+
r._headers = [
|
|
280
|
+
(b"content-type", b"text/event-stream"),
|
|
281
|
+
(b"cache-control", b"no-cache"),
|
|
282
|
+
(b"connection", b"keep-alive"),
|
|
283
|
+
]
|
|
284
|
+
return r
|
|
285
|
+
|
|
286
|
+
def write_claude_config(self, port: int = 7145):
|
|
287
|
+
"""Write/update .claude/settings.json with this MCP server config."""
|
|
288
|
+
config_dir = Path(".claude")
|
|
289
|
+
config_dir.mkdir(exist_ok=True)
|
|
290
|
+
config_file = config_dir / "settings.json"
|
|
291
|
+
|
|
292
|
+
config = {}
|
|
293
|
+
if config_file.exists():
|
|
294
|
+
try:
|
|
295
|
+
config = json.loads(config_file.read_text())
|
|
296
|
+
except (json.JSONDecodeError, OSError):
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
if "mcpServers" not in config:
|
|
300
|
+
config["mcpServers"] = {}
|
|
301
|
+
|
|
302
|
+
server_key = self.name.lower().replace(" ", "-")
|
|
303
|
+
config["mcpServers"][server_key] = {
|
|
304
|
+
"url": f"http://localhost:{port}{self.path}/sse"
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
config_file.write_text(json.dumps(config, indent=2) + "\n")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ── Decorator API ──────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
# Default server instance — tools/resources registered via decorators
|
|
313
|
+
# attach to this instance. Developers can create their own McpServer.
|
|
314
|
+
_default_server: McpServer | None = None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _get_default_server() -> McpServer:
|
|
318
|
+
global _default_server
|
|
319
|
+
if _default_server is None:
|
|
320
|
+
_default_server = McpServer("/__dev/mcp", name="Tina4 Dev Tools")
|
|
321
|
+
return _default_server
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def mcp_tool(name: str = "", description: str = "", server: McpServer | None = None):
|
|
325
|
+
"""Decorator to register a function or method as an MCP tool.
|
|
326
|
+
|
|
327
|
+
Usage:
|
|
328
|
+
@mcp_tool("lookup_invoice", description="Find invoice by number")
|
|
329
|
+
def lookup_invoice(invoice_no: str):
|
|
330
|
+
return db.fetch_one("SELECT * FROM invoices WHERE invoice_no = ?", [invoice_no])
|
|
331
|
+
|
|
332
|
+
# On a class method
|
|
333
|
+
class Service:
|
|
334
|
+
@mcp_tool("get_report")
|
|
335
|
+
def report(self, month: str):
|
|
336
|
+
return generate_report(month)
|
|
337
|
+
"""
|
|
338
|
+
def decorator(func):
|
|
339
|
+
tool_name = name or func.__name__
|
|
340
|
+
tool_desc = description or (func.__doc__ or "").strip()
|
|
341
|
+
target = server or _get_default_server()
|
|
342
|
+
target.register_tool(tool_name, func, tool_desc)
|
|
343
|
+
func._mcp_tool_name = tool_name
|
|
344
|
+
return func
|
|
345
|
+
return decorator
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def mcp_resource(uri: str, description: str = "", mime_type: str = "application/json", server: McpServer | None = None):
|
|
349
|
+
"""Decorator to register a function as an MCP resource.
|
|
350
|
+
|
|
351
|
+
Usage:
|
|
352
|
+
@mcp_resource("app://tables", description="Database tables")
|
|
353
|
+
def list_tables():
|
|
354
|
+
return db.get_tables()
|
|
355
|
+
"""
|
|
356
|
+
def decorator(func):
|
|
357
|
+
target = server or _get_default_server()
|
|
358
|
+
target.register_resource(uri, func, description, mime_type)
|
|
359
|
+
func._mcp_resource_uri = uri
|
|
360
|
+
return func
|
|
361
|
+
return decorator
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# JSON-RPC 2.0 codec for MCP protocol.
|
|
2
|
+
"""
|
|
3
|
+
Encode/decode JSON-RPC 2.0 messages used by the Model Context Protocol.
|
|
4
|
+
Zero dependencies — stdlib json only.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
# Standard JSON-RPC 2.0 error codes
|
|
9
|
+
PARSE_ERROR = -32700
|
|
10
|
+
INVALID_REQUEST = -32600
|
|
11
|
+
METHOD_NOT_FOUND = -32601
|
|
12
|
+
INVALID_PARAMS = -32602
|
|
13
|
+
INTERNAL_ERROR = -32603
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def encode_response(request_id, result):
|
|
17
|
+
"""Encode a successful JSON-RPC 2.0 response."""
|
|
18
|
+
return json.dumps({
|
|
19
|
+
"jsonrpc": "2.0",
|
|
20
|
+
"id": request_id,
|
|
21
|
+
"result": result,
|
|
22
|
+
}, default=str, separators=(",", ":"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def encode_error(request_id, code: int, message: str, data=None):
|
|
26
|
+
"""Encode a JSON-RPC 2.0 error response."""
|
|
27
|
+
error = {"code": code, "message": message}
|
|
28
|
+
if data is not None:
|
|
29
|
+
error["data"] = data
|
|
30
|
+
return json.dumps({
|
|
31
|
+
"jsonrpc": "2.0",
|
|
32
|
+
"id": request_id,
|
|
33
|
+
"error": error,
|
|
34
|
+
}, default=str, separators=(",", ":"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def encode_notification(method: str, params=None):
|
|
38
|
+
"""Encode a JSON-RPC 2.0 notification (no id)."""
|
|
39
|
+
msg = {"jsonrpc": "2.0", "method": method}
|
|
40
|
+
if params is not None:
|
|
41
|
+
msg["params"] = params
|
|
42
|
+
return json.dumps(msg, default=str, separators=(",", ":"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def decode_request(data: str | bytes | dict) -> tuple:
|
|
46
|
+
"""Decode a JSON-RPC 2.0 request.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
(method, params, request_id) — request_id is None for notifications.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If the message is malformed.
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(data, (str, bytes)):
|
|
55
|
+
try:
|
|
56
|
+
msg = json.loads(data)
|
|
57
|
+
except json.JSONDecodeError as e:
|
|
58
|
+
raise ValueError(f"Invalid JSON: {e}") from e
|
|
59
|
+
else:
|
|
60
|
+
msg = data
|
|
61
|
+
|
|
62
|
+
if not isinstance(msg, dict):
|
|
63
|
+
raise ValueError("Message must be a JSON object")
|
|
64
|
+
|
|
65
|
+
if msg.get("jsonrpc") != "2.0":
|
|
66
|
+
raise ValueError("Missing or invalid jsonrpc version")
|
|
67
|
+
|
|
68
|
+
method = msg.get("method")
|
|
69
|
+
if not method or not isinstance(method, str):
|
|
70
|
+
raise ValueError("Missing or invalid method")
|
|
71
|
+
|
|
72
|
+
params = msg.get("params", {})
|
|
73
|
+
request_id = msg.get("id") # None for notifications
|
|
74
|
+
|
|
75
|
+
return method, params, request_id
|