tina4-python 3.13.49__tar.gz → 3.13.51__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.
Files changed (158) hide show
  1. {tina4_python-3.13.49 → tina4_python-3.13.51}/PKG-INFO +1 -1
  2. {tina4_python-3.13.49 → tina4_python-3.13.51}/pyproject.toml +1 -1
  3. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/dev_admin/__init__.py +93 -31
  5. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/mcp/__init__.py +173 -34
  6. {tina4_python-3.13.49 → tina4_python-3.13.51}/.gitignore +0 -0
  7. {tina4_python-3.13.49 → tina4_python-3.13.51}/README.md +0 -0
  8. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/CLAUDE.md +0 -0
  9. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/HtmlElement.py +0 -0
  10. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/Testing.py +0 -0
  11. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/ai/__init__.py +0 -0
  12. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/api/__init__.py +0 -0
  13. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/auth/__init__.py +0 -0
  14. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/cache/__init__.py +0 -0
  15. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/cli/__init__.py +0 -0
  16. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/container/__init__.py +0 -0
  17. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/core/__init__.py +0 -0
  18. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/core/cache.py +0 -0
  19. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/core/constants.py +0 -0
  20. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/core/events.py +0 -0
  21. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/core/middleware.py +0 -0
  22. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/core/rate_limiter.py +0 -0
  23. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/core/request.py +0 -0
  24. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/core/response.py +0 -0
  25. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/core/router.py +0 -0
  26. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/core/server.py +0 -0
  27. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/crud/__init__.py +0 -0
  28. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/database/__init__.py +0 -0
  29. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/database/adapter.py +0 -0
  30. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/database/connection.py +0 -0
  31. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/database/firebird.py +0 -0
  32. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/database/mongodb.py +0 -0
  33. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/database/mssql.py +0 -0
  34. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/database/mysql.py +0 -0
  35. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/database/odbc.py +0 -0
  36. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/database/postgres.py +0 -0
  37. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/database/sqlite.py +0 -0
  38. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/debug/__init__.py +0 -0
  39. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/debug/error_overlay.py +0 -0
  40. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/dev_admin/metrics.py +0 -0
  41. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/dev_admin/plan.py +0 -0
  42. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/dev_admin/project_index.py +0 -0
  43. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/docs.py +0 -0
  44. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/docstore/__init__.py +0 -0
  45. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/dotenv/__init__.py +0 -0
  46. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/env.py +0 -0
  47. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/frond/FROND.md +0 -0
  48. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/frond/__init__.py +0 -0
  49. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/frond/engine.py +0 -0
  50. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/auth/meta.json +0 -0
  51. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  52. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/database/meta.json +0 -0
  53. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  54. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/error-overlay/meta.json +0 -0
  55. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  56. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/orm/meta.json +0 -0
  57. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  58. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  59. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/queue/meta.json +0 -0
  60. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  61. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/rest-api/meta.json +0 -0
  62. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  63. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/templates/meta.json +0 -0
  64. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  65. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  66. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/graphql/__init__.py +0 -0
  67. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/i18n/__init__.py +0 -0
  68. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/mcp/protocol.py +0 -0
  69. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/mcp/tools.py +0 -0
  70. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/messenger/__init__.py +0 -0
  71. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/migration/__init__.py +0 -0
  72. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/migration/runner.py +0 -0
  73. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/orm/__init__.py +0 -0
  74. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/orm/fields.py +0 -0
  75. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/orm/model.py +0 -0
  76. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/public/__feedback/widget.js +0 -0
  77. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/public/css/tina4.css +0 -0
  78. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/public/css/tina4.min.css +0 -0
  79. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/public/favicon.ico +0 -0
  80. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/public/images/logo.svg +0 -0
  81. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  82. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/public/js/frond.js +0 -0
  83. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/public/js/frond.min.js +0 -0
  84. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  85. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  86. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/public/js/tina4.min.js +0 -0
  87. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/public/js/tina4js.min.js +0 -0
  88. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/public/swagger/index.html +0 -0
  89. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  90. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/query_builder/__init__.py +0 -0
  91. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/queue/__init__.py +0 -0
  92. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/queue/job.py +0 -0
  93. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/queue/kafka_backend.py +0 -0
  94. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/queue/lite_backend.py +0 -0
  95. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/queue/mongo_backend.py +0 -0
  96. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/queue/rabbitmq_backend.py +0 -0
  97. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/queue_backends/__init__.py +0 -0
  98. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/queue_backends/kafka_backend.py +0 -0
  99. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/queue_backends/mongo_backend.py +0 -0
  100. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  101. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/__init__.py +0 -0
  102. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  103. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/_badges.scss +0 -0
  104. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  105. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/_cards.scss +0 -0
  106. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/_forms.scss +0 -0
  107. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/_grid.scss +0 -0
  108. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/_modals.scss +0 -0
  109. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/_nav.scss +0 -0
  110. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/_reset.scss +0 -0
  111. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/_tables.scss +0 -0
  112. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/_typography.scss +0 -0
  113. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  114. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/_variables.scss +0 -0
  115. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/base.scss +0 -0
  116. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/colors.scss +0 -0
  117. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/scss/tina4css/tina4.scss +0 -0
  118. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/seeder/__init__.py +0 -0
  119. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/service/__init__.py +0 -0
  120. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/session/__init__.py +0 -0
  121. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/session_handlers/__init__.py +0 -0
  122. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  123. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/session_handlers/redis_handler.py +0 -0
  124. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/session_handlers/valkey_handler.py +0 -0
  125. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/swagger/__init__.py +0 -0
  126. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/components/crud.twig +0 -0
  127. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  128. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  129. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/docker/python/Dockerfile +0 -0
  130. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  131. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/errors/302.twig +0 -0
  132. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/errors/401.twig +0 -0
  133. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/errors/403.twig +0 -0
  134. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/errors/404.twig +0 -0
  135. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/errors/500.twig +0 -0
  136. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/errors/502.twig +0 -0
  137. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/errors/503.twig +0 -0
  138. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/errors/base.twig +0 -0
  139. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/frontend/README.md +0 -0
  140. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/templates/readme.md +0 -0
  141. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/test/__init__.py +0 -0
  142. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/test_client/__init__.py +0 -0
  143. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  144. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  145. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  146. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  147. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  148. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  149. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  150. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  151. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  152. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  153. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  154. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  155. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/validator/__init__.py +0 -0
  156. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/websocket/__init__.py +0 -0
  157. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/websocket/backplane.py +0 -0
  158. {tina4_python-3.13.49 → tina4_python-3.13.51}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.13.49
3
+ Version: 3.13.51
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.49"
3
+ version = "3.13.51"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.13.49"
11
+ __version__ = "3.13.51"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -320,8 +320,9 @@ def write_mcp_discovery_file() -> None:
320
320
  expected = {
321
321
  "mcpServers": {
322
322
  "tina4-live-docs": {
323
- "url": f"http://localhost:{port}/__dev/api/mcp",
324
- "description": "Live API docs for this Tina4 project (framework + user code)",
323
+ "type": "http",
324
+ "url": f"http://localhost:{port}/__dev/mcp",
325
+ "description": "Live API docs + dev tools for this Tina4 project (framework + user code)",
325
326
  }
326
327
  }
327
328
  }
@@ -485,11 +486,13 @@ def get_api_handlers() -> dict:
485
486
  # @mcp_tool decorator appear in both immediately.
486
487
  "/__dev/api/mcp/tools": ("GET", _api_mcp_tools),
487
488
  "/__dev/api/mcp/call": ("POST", _api_mcp_call),
488
- # JSON-RPC + SSE surface for real MCP clients (Claude Desktop/Code).
489
- # Same registry as the REST shim above; this is what makes /__dev/mcp
490
- # actually reachable as an MCP server (previously defined but unmounted).
491
- "/__dev/mcp": ("POST", _api_mcp_rpc),
492
- "/__dev/mcp/message": ("POST", _api_mcp_rpc),
489
+ # MCP transport surface for real clients (Claude Code / Desktop).
490
+ # Same registry as the REST shim above. /__dev/mcp is the Streamable
491
+ # HTTP endpoint (POST message + DELETE session; "*" so one handler
492
+ # switches on the method); /message + /sse are the legacy HTTP+SSE
493
+ # transport, kept working for older SSE-only clients.
494
+ "/__dev/mcp": ("*", _api_mcp_endpoint),
495
+ "/__dev/mcp/message": ("POST", _api_mcp_message),
493
496
  "/__dev/mcp/sse": ("GET", _api_mcp_sse),
494
497
  # ── Scaffold REST shim ──
495
498
  # Wraps the tina4python CLI's `generate <kind> <name>` so the
@@ -2964,43 +2967,102 @@ async def _api_mcp_call(request, response):
2964
2967
  return response({"ok": False, "name": name, "error": str(exc)}, 500)
2965
2968
 
2966
2969
 
2967
- # ─── MCP JSON-RPC + SSE endpoint ───────────────────────────────────
2970
+ # ─── MCP transport endpoint ────────────────────────────────────────
2968
2971
  #
2969
- # The protocol surface real MCP clients (Claude Desktop/Code) speak
2970
- # JSON-RPC 2.0 over the HTTP+SSE transport. Mounted on the running dev
2971
- # server so each `tina4 serve`d project exposes its OWN endpoint, giving
2972
- # an AI agent live access scoped to that project. Shares the same
2973
- # `_default_server` tool registry as the REST shim above, so every
2974
- # @mcp_tool shows up on both surfaces.
2975
-
2976
- async def _api_mcp_rpc(request, response):
2977
- """POST the JSON-RPC endpoint. Mounted at /__dev/mcp and
2978
- /__dev/mcp/message. Forwards to the default MCP server's
2979
- handle_message() and returns the JSON-RPC response; notifications
2980
- (no id) yield an empty 204.
2981
- """
2972
+ # The protocol surface real MCP clients (Claude Code / Claude Desktop)
2973
+ # speak. Mounted on the running dev server so each `tina4 serve`d project
2974
+ # exposes its OWN endpoint, giving an AI agent live access scoped to that
2975
+ # project. Shares the same `_default_server` tool registry as the REST
2976
+ # shim above, so every @mcp_tool shows up on all surfaces.
2977
+ #
2978
+ # Two transports live here:
2979
+ # * Streamable HTTP (current) — POST /__dev/mcp with the JSON-RPC message;
2980
+ # the response comes back inline as application/json, and initialize
2981
+ # issues an Mcp-Session-Id header the client echoes on later requests.
2982
+ # GET is 405 (this server initiates no messages) and DELETE ends a
2983
+ # session.
2984
+ # * Legacy HTTP+SSE (2024-11-05) — GET /__dev/mcp/sse opens a persistent
2985
+ # stream that first names the POST endpoint, then delivers each JSON-RPC
2986
+ # response as an SSE `message` event; POST /__dev/mcp/message feeds it.
2987
+ # Kept working for older SSE-only clients.
2988
+
2989
+
2990
+ def _mcp_session_header(request) -> str:
2991
+ """Read the Mcp-Session-Id request header (empty string when absent)."""
2992
+ headers = getattr(request, "headers", None) or {}
2993
+ return headers.get("mcp-session-id", "") or ""
2994
+
2995
+
2996
+ def _mcp_apply(response, outcome):
2997
+ """Apply a dispatch_http/dispatch_sse_message result dict (status,
2998
+ headers, body) onto the dev-admin response."""
2982
2999
  import json as _json
3000
+ for name, value in outcome["headers"].items():
3001
+ response.header(name, value)
3002
+ body = outcome["body"]
3003
+ if not body:
3004
+ return response("", outcome["status"])
3005
+ return response(_json.loads(body), outcome["status"])
3006
+
3007
+
3008
+ async def _api_mcp_endpoint(request, response):
3009
+ """The Streamable HTTP endpoint at /__dev/mcp (method wildcard).
3010
+
3011
+ POST — a JSON-RPC message; response is inline application/json.
3012
+ GET — 405, this server pushes no unsolicited messages (use the
3013
+ legacy /sse stream if you need server-initiated framing).
3014
+ DELETE — terminate the session named by Mcp-Session-Id.
3015
+ """
2983
3016
  from tina4_python.mcp import _get_default_server
2984
3017
  if not _mcp_request_allowed(request):
2985
3018
  return response({"error": "MCP disabled"}, 404)
2986
3019
  server = _get_default_server()
2987
- body = request.body
2988
- raw = body if isinstance(body, (dict, str)) else str(body)
2989
- result = server.handle_message(raw)
2990
- if not result:
3020
+ method = (getattr(request, "method", "") or "GET").upper()
3021
+
3022
+ if method == "POST":
3023
+ outcome = server.dispatch_http(request.body, _mcp_session_header(request))
3024
+ return _mcp_apply(response, outcome)
3025
+
3026
+ if method == "DELETE":
3027
+ server.close_session(_mcp_session_header(request))
2991
3028
  return response("", 204)
2992
- return response(_json.loads(result))
3029
+
3030
+ # GET (and anything else): no server-initiated stream on this endpoint.
3031
+ response.header("Allow", "POST, DELETE")
3032
+ return response({"error": "method not allowed"}, 405)
3033
+
3034
+
3035
+ async def _api_mcp_message(request, response):
3036
+ """POST /__dev/mcp/message — legacy HTTP+SSE message sink.
3037
+
3038
+ Delivers the JSON-RPC response on the matching open SSE stream (202
3039
+ here); with no open stream it degrades to an inline Streamable HTTP
3040
+ response, so the path also serves a plain POST client.
3041
+ """
3042
+ from tina4_python.mcp import _get_default_server
3043
+ if not _mcp_request_allowed(request):
3044
+ return response({"error": "MCP disabled"}, 404)
3045
+ server = _get_default_server()
3046
+ params = getattr(request, "params", None) or {}
3047
+ session_id = params.get("sessionId") or _mcp_session_header(request)
3048
+ outcome = server.dispatch_sse_message(request.body, session_id)
3049
+ return _mcp_apply(response, outcome)
2993
3050
 
2994
3051
 
2995
3052
  async def _api_mcp_sse(request, response):
2996
- """GET — SSE handshake. Emits the `endpoint` event telling the client
2997
- where to POST JSON-RPC messages, per the MCP HTTP+SSE transport.
3053
+ """GET /__dev/mcp/sse legacy HTTP+SSE stream.
3054
+
3055
+ Opens a persistent SSE connection: first the `endpoint` event naming the
3056
+ POST target (session-tagged), then each JSON-RPC response as it arrives.
2998
3057
  """
3058
+ from tina4_python.mcp import _get_default_server
2999
3059
  if not _mcp_request_allowed(request):
3000
3060
  return response({"error": "MCP disabled"}, 404)
3001
- base = request.path.rsplit("/sse", 1)[0]
3002
- sse = f"event: endpoint\ndata: {base}/message\n\n"
3003
- return response(sse, 200, "text/event-stream")
3061
+ server = _get_default_server()
3062
+ session_id = server.open_session()
3063
+ base = getattr(request, "path", "/__dev/mcp/sse").rsplit("/sse", 1)[0]
3064
+ endpoint_url = f"{base}/message?sessionId={session_id}"
3065
+ return response.stream(server.sse_stream(session_id, endpoint_url))
3004
3066
 
3005
3067
 
3006
3068
  # ─── Scaffold REST shim ────────────────────────────────────────────
@@ -22,6 +22,8 @@ import os
22
22
  import json
23
23
  import inspect
24
24
  import socket
25
+ import secrets
26
+ import time
25
27
  from pathlib import Path
26
28
 
27
29
  from .protocol import (
@@ -31,6 +33,12 @@ from .protocol import (
31
33
  INVALID_PARAMS, INTERNAL_ERROR,
32
34
  )
33
35
 
36
+ # MCP protocol versions this server can speak, newest first. The 2025-*
37
+ # versions are the Streamable HTTP era; 2024-11-05 is the legacy HTTP+SSE
38
+ # transport we still accept for older clients (Claude Desktop et al.).
39
+ SUPPORTED_PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
40
+ LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0]
41
+
34
42
  # Re-export protocol helpers as public API (parity with PHP/Ruby/Node)
35
43
  __all__ = [
36
44
  "McpServer", "mcp_tool", "mcp_resource",
@@ -192,8 +200,126 @@ class McpServer:
192
200
  self._tools: dict[str, dict] = {}
193
201
  self._resources: dict[str, dict] = {}
194
202
  self._initialized = False
203
+ # Streamable HTTP session ids issued at initialize time, mapped to
204
+ # their creation timestamp. Transport layers echo the id back in the
205
+ # Mcp-Session-Id header; a request bearing an unknown id gets a 404 so
206
+ # the client knows to re-initialize.
207
+ self._sessions: dict[str, float] = {}
208
+ # Open legacy HTTP+SSE streams keyed by session id. The GET /sse
209
+ # handler registers an asyncio.Queue here; the POST /message handler
210
+ # pushes each JSON-RPC response onto it so it streams back on the open
211
+ # connection (the 2024-11-05 transport). Empty for Streamable HTTP.
212
+ self._sse_queues: dict = {}
195
213
  McpServer._instances.append(self)
196
214
 
215
+ # ── Session lifecycle (Streamable HTTP + legacy SSE correlation) ──
216
+
217
+ def open_session(self) -> str:
218
+ """Mint a new session id and remember it. Called on `initialize`."""
219
+ sid = secrets.token_hex(16)
220
+ self._sessions[sid] = time.time()
221
+ return sid
222
+
223
+ def is_valid_session(self, session_id: str) -> bool:
224
+ """True when `session_id` was issued by this server and still open."""
225
+ return bool(session_id) and session_id in self._sessions
226
+
227
+ def close_session(self, session_id: str) -> bool:
228
+ """Forget a session (client DELETE or SSE stream close). Returns
229
+ True when a live session was actually removed."""
230
+ return self._sessions.pop(session_id, None) is not None
231
+
232
+ def negotiate_protocol_version(self, requested: str | None) -> str:
233
+ """Pick the protocol version to run on. Echo the client's requested
234
+ version when we support it (proper negotiation), else fall back to the
235
+ newest version we speak so an unversioned/old client still connects."""
236
+ if requested in SUPPORTED_PROTOCOL_VERSIONS:
237
+ return requested
238
+ return LATEST_PROTOCOL_VERSION
239
+
240
+ def _peek_method(self, raw_data) -> str | None:
241
+ """Read the JSON-RPC `method` from a raw request without dispatching.
242
+ Used by the transport to spot `initialize` (mint a session) before it
243
+ hands the message to handle_message()."""
244
+ try:
245
+ obj = raw_data if isinstance(raw_data, dict) else json.loads(raw_data or "{}")
246
+ except (ValueError, TypeError):
247
+ return None
248
+ return obj.get("method") if isinstance(obj, dict) else None
249
+
250
+ def dispatch_http(self, raw_data, session_id: str = "") -> dict:
251
+ """Transport-agnostic Streamable HTTP POST handler.
252
+
253
+ Every language's transport calls this so the wire behaviour stays
254
+ identical:
255
+ - `initialize` mints a session id, returned in the Mcp-Session-Id
256
+ response header.
257
+ - a non-initialize request carrying an unknown session id is a 404
258
+ (JSON-RPC error) so the client knows to re-initialize.
259
+ - a notification / response-only POST (no id) yields 202 with an
260
+ empty body.
261
+ - anything else returns 200 with the JSON-RPC response as
262
+ application/json, which the MCP Streamable HTTP spec permits for a
263
+ POST that resolves to a single response.
264
+
265
+ Returns {"status": int, "headers": {name: value}, "body": str}.
266
+ """
267
+ is_init = self._peek_method(raw_data) == "initialize"
268
+ if not is_init and session_id and not self.is_valid_session(session_id):
269
+ return {
270
+ "status": 404,
271
+ "headers": {},
272
+ "body": encode_error(None, INVALID_REQUEST, "session not found"),
273
+ }
274
+ body = self.handle_message(raw_data)
275
+ headers: dict[str, str] = {}
276
+ if is_init:
277
+ headers["Mcp-Session-Id"] = self.open_session()
278
+ if not body:
279
+ return {"status": 202, "headers": headers, "body": ""}
280
+ return {"status": 200, "headers": headers, "body": body}
281
+
282
+ def dispatch_sse_message(self, raw_data, session_id: str = "") -> dict:
283
+ """Legacy HTTP+SSE POST /message handler.
284
+
285
+ When a live SSE stream is open for `session_id`, run the message and
286
+ push the JSON-RPC response down that stream (returning 202 here, per
287
+ the 2024-11-05 transport). With no open stream this degrades to an
288
+ inline Streamable HTTP response, so the same /message path serves both
289
+ a legacy SSE client and a plain POST client.
290
+ """
291
+ queue = self._sse_queues.get(session_id) if session_id else None
292
+ if queue is None:
293
+ return self.dispatch_http(raw_data, session_id)
294
+ body = self.handle_message(raw_data)
295
+ if body:
296
+ queue.put_nowait(body)
297
+ return {"status": 202, "headers": {}, "body": ""}
298
+
299
+ async def sse_stream(self, session_id: str, endpoint_url: str, keepalive: float = 15.0):
300
+ """Async generator of SSE frames for the legacy HTTP+SSE transport.
301
+
302
+ Emits the `endpoint` event first (telling the client where to POST),
303
+ then each queued JSON-RPC response as it arrives, with periodic
304
+ keep-alive comment frames so proxies do not close an idle connection.
305
+ Registers the per-session queue up front and tears it down (plus the
306
+ session) when the client disconnects and the generator is closed.
307
+ """
308
+ import asyncio
309
+ queue: asyncio.Queue = asyncio.Queue()
310
+ self._sse_queues[session_id] = queue
311
+ try:
312
+ yield f"event: endpoint\ndata: {endpoint_url}\n\n"
313
+ while True:
314
+ try:
315
+ message = await asyncio.wait_for(queue.get(), timeout=keepalive)
316
+ yield f"event: message\ndata: {message}\n\n"
317
+ except asyncio.TimeoutError:
318
+ yield ": keep-alive\n\n"
319
+ finally:
320
+ self._sse_queues.pop(session_id, None)
321
+ self.close_session(session_id)
322
+
197
323
  def register_tool(self, name: str, handler, description: str = "", schema: dict | None = None):
198
324
  """Register a tool callable."""
199
325
  if schema is None:
@@ -244,10 +370,14 @@ class McpServer:
244
370
  return encode_error(request_id, INTERNAL_ERROR, str(e))
245
371
 
246
372
  def _handle_initialize(self, params: dict) -> dict:
247
- """Handle initialize request — return server capabilities."""
373
+ """Handle initialize request — negotiate the protocol version and
374
+ return server capabilities. We echo the client's requested version
375
+ when we support it and otherwise offer our newest, so both a current
376
+ Streamable HTTP client and a legacy 2024-11-05 client connect."""
248
377
  self._initialized = True
378
+ requested = (params or {}).get("protocolVersion")
249
379
  return {
250
- "protocolVersion": "2024-11-05",
380
+ "protocolVersion": self.negotiate_protocol_version(requested),
251
381
  "capabilities": {
252
382
  "tools": {"listChanged": False},
253
383
  "resources": {"subscribe": False, "listChanged": False},
@@ -344,44 +474,52 @@ class McpServer:
344
474
  def register_routes(self, router_module):
345
475
  """Register HTTP routes for this MCP server on the Tina4 router.
346
476
 
347
- Registers:
348
- POST {path}/message JSON-RPC message endpoint
349
- GET {path}/sse — SSE endpoint for streaming
477
+ Mounts both supported transports on `path`:
478
+ POST {path} Streamable HTTP (the current transport)
479
+ POST {path}/message legacy HTTP+SSE message sink (+ inline fallback)
480
+ GET {path}/sse — legacy HTTP+SSE stream (persistent)
481
+
482
+ A Streamable HTTP client (Claude Code `--transport http`) POSTs to
483
+ `{path}` and reads the JSON-RPC response inline, with an Mcp-Session-Id
484
+ header issued on initialize. A legacy SSE client (Claude Desktop,
485
+ `--transport sse`) GETs `{path}/sse`, receives the endpoint event, and
486
+ its responses stream back on that connection.
350
487
  """
351
488
  server = self
352
- msg_path = f"{self.path}/message"
353
- sse_path = f"{self.path}/sse"
354
489
 
355
- @router_module.post(msg_path)
490
+ def _session_header(request) -> str:
491
+ headers = getattr(request, "headers", None) or {}
492
+ return headers.get("mcp-session-id", "") or ""
493
+
494
+ def _apply(response, outcome):
495
+ for name, value in outcome["headers"].items():
496
+ response.header(name, value)
497
+ body = outcome["body"]
498
+ if not body:
499
+ return response("", outcome["status"])
500
+ return response(json.loads(body), outcome["status"])
501
+
502
+ @router_module.post(self.path)
503
+ @router_module.noauth()
504
+ async def mcp_streamable(request, response):
505
+ outcome = server.dispatch_http(request.body, _session_header(request))
506
+ return _apply(response, outcome)
507
+
508
+ @router_module.post(f"{self.path}/message")
356
509
  @router_module.noauth()
357
510
  async def mcp_message(request, response):
358
- body = request.body
359
- if isinstance(body, dict):
360
- raw = body
361
- else:
362
- raw = body if isinstance(body, str) else str(body)
363
- result = server.handle_message(raw)
364
- if not result:
365
- return response("", 204)
366
- return response(json.loads(result))
367
-
368
- @router_module.get(sse_path)
511
+ params = getattr(request, "params", None) or {}
512
+ session_id = params.get("sessionId") or _session_header(request)
513
+ outcome = server.dispatch_sse_message(request.body, session_id)
514
+ return _apply(response, outcome)
515
+
516
+ @router_module.get(f"{self.path}/sse")
369
517
  @router_module.noauth()
370
518
  async def mcp_sse(request, response):
371
- # SSE endpoint — send initial endpoint message
372
- endpoint_url = f"{request.url.rsplit('/sse', 1)[0]}/message"
373
- sse_data = f"event: endpoint\ndata: {endpoint_url}\n\n"
374
- from tina4_python.core.response import Response as Resp
375
- r = Resp()
376
- r.status_code = 200
377
- r.content_type = "text/event-stream"
378
- r.content = sse_data.encode()
379
- r._headers = [
380
- (b"content-type", b"text/event-stream"),
381
- (b"cache-control", b"no-cache"),
382
- (b"connection", b"keep-alive"),
383
- ]
384
- return r
519
+ session_id = server.open_session()
520
+ base = getattr(request, "path", self.path).rsplit("/sse", 1)[0]
521
+ endpoint_url = f"{base}/message?sessionId={session_id}"
522
+ return response.stream(server.sse_stream(session_id, endpoint_url))
385
523
 
386
524
  def write_claude_config(self, port: int = 7145):
387
525
  """Write/update .claude/settings.json with this MCP server config."""
@@ -401,7 +539,8 @@ class McpServer:
401
539
 
402
540
  server_key = self.name.lower().replace(" ", "-")
403
541
  config["mcpServers"][server_key] = {
404
- "url": f"http://localhost:{port}{self.path}/sse"
542
+ "type": "http",
543
+ "url": f"http://localhost:{port}{self.path}",
405
544
  }
406
545
 
407
546
  config_file.write_text(json.dumps(config, indent=2) + "\n")
File without changes