stkit 17.2.1__tar.gz → 17.2.3__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 (238) hide show
  1. {stkit-17.2.1 → stkit-17.2.3}/CHANGELOG.md +21 -0
  2. {stkit-17.2.1 → stkit-17.2.3}/PKG-INFO +2 -2
  3. {stkit-17.2.1 → stkit-17.2.3}/pyproject.toml +2 -2
  4. {stkit-17.2.1 → stkit-17.2.3}/stk/__init__.py +1 -1
  5. {stkit-17.2.1 → stkit-17.2.3}/stk/app.py +6 -3
  6. {stkit-17.2.1 → stkit-17.2.3}/stk/cli/base.py +3 -0
  7. {stkit-17.2.1 → stkit-17.2.3}/stk/cli/develop.py +2 -5
  8. {stkit-17.2.1 → stkit-17.2.3}/stk/mcp/__init__.py +10 -0
  9. {stkit-17.2.1 → stkit-17.2.3}/stk/mcp/models.py +1 -1
  10. {stkit-17.2.1 → stkit-17.2.3}/stk/migrations/versions/stk_0005_mcp_tokens.py +13 -10
  11. stkit-17.2.3/stk/migrations/versions/stk_0006_stk_mcp_token.py +69 -0
  12. {stkit-17.2.1 → stkit-17.2.3}/stk/tasks/__init__.py +33 -7
  13. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/index.html +1 -1
  14. {stkit-17.2.1 → stkit-17.2.3}/stk/utils/netguard.py +78 -24
  15. {stkit-17.2.1 → stkit-17.2.3}/tests/test_cli.py +17 -0
  16. {stkit-17.2.1 → stkit-17.2.3}/tests/test_jobs.py +17 -1
  17. {stkit-17.2.1 → stkit-17.2.3}/tests/test_mcp.py +16 -1
  18. stkit-17.2.3/tests/test_mcp_migration.py +62 -0
  19. {stkit-17.2.1 → stkit-17.2.3}/tests/test_netguard.py +59 -1
  20. {stkit-17.2.1 → stkit-17.2.3}/.gitignore +0 -0
  21. {stkit-17.2.1 → stkit-17.2.3}/LICENSE +0 -0
  22. {stkit-17.2.1 → stkit-17.2.3}/README.md +0 -0
  23. {stkit-17.2.1 → stkit-17.2.3}/SECURITY.md +0 -0
  24. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/.env-sample +0 -0
  25. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/.gitignore +0 -0
  26. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/README.md +0 -0
  27. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/minimal/__init__.py +0 -0
  28. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/minimal/app.py +0 -0
  29. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/minimal/migrations/364232ae77d5_add_note.py +0 -0
  30. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/minimal/note/__init__.py +0 -0
  31. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/minimal/note/models.py +0 -0
  32. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/minimal/note/templates/note/index.html +0 -0
  33. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/minimal/note/views.py +0 -0
  34. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/minimal/settings.py +0 -0
  35. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/minimal/templates/layout.html +0 -0
  36. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/pyproject.toml +0 -0
  37. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/tests/__init__.py +0 -0
  38. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/tests/test_app.py +0 -0
  39. {stkit-17.2.1 → stkit-17.2.3}/examples/minimal/uv.lock +0 -0
  40. {stkit-17.2.1 → stkit-17.2.3}/stk/__main__.py +0 -0
  41. {stkit-17.2.1 → stkit-17.2.3}/stk/agent_login.py +0 -0
  42. {stkit-17.2.1 → stkit-17.2.3}/stk/checks.py +0 -0
  43. {stkit-17.2.1 → stkit-17.2.3}/stk/cli/__init__.py +0 -0
  44. {stkit-17.2.1 → stkit-17.2.3}/stk/cli/agent.py +0 -0
  45. {stkit-17.2.1 → stkit-17.2.3}/stk/cli/copy_rules.py +0 -0
  46. {stkit-17.2.1 → stkit-17.2.3}/stk/cli/database.py +0 -0
  47. {stkit-17.2.1 → stkit-17.2.3}/stk/cli/doctor.py +0 -0
  48. {stkit-17.2.1 → stkit-17.2.3}/stk/cli/main.py +0 -0
  49. {stkit-17.2.1 → stkit-17.2.3}/stk/cli/reports.py +0 -0
  50. {stkit-17.2.1 → stkit-17.2.3}/stk/cli/smoke.py +0 -0
  51. {stkit-17.2.1 → stkit-17.2.3}/stk/cli/users.py +0 -0
  52. {stkit-17.2.1 → stkit-17.2.3}/stk/cli/watch.py +0 -0
  53. {stkit-17.2.1 → stkit-17.2.3}/stk/extensions.py +0 -0
  54. {stkit-17.2.1 → stkit-17.2.3}/stk/files/__init__.py +0 -0
  55. {stkit-17.2.1 → stkit-17.2.3}/stk/files/models.py +0 -0
  56. {stkit-17.2.1 → stkit-17.2.3}/stk/files/store.py +0 -0
  57. {stkit-17.2.1 → stkit-17.2.3}/stk/files/views.py +0 -0
  58. {stkit-17.2.1 → stkit-17.2.3}/stk/mcp/server.py +0 -0
  59. {stkit-17.2.1 → stkit-17.2.3}/stk/mcp/views.py +0 -0
  60. {stkit-17.2.1 → stkit-17.2.3}/stk/migrations/__init__.py +0 -0
  61. {stkit-17.2.1 → stkit-17.2.3}/stk/migrations/env.py +0 -0
  62. {stkit-17.2.1 → stkit-17.2.3}/stk/migrations/script.py.mako +0 -0
  63. {stkit-17.2.1 → stkit-17.2.3}/stk/migrations/versions/stk_0001_initial_schema.py +0 -0
  64. {stkit-17.2.1 → stkit-17.2.3}/stk/migrations/versions/stk_0002_jobs.py +0 -0
  65. {stkit-17.2.1 → stkit-17.2.3}/stk/migrations/versions/stk_0003_files.py +0 -0
  66. {stkit-17.2.1 → stkit-17.2.3}/stk/migrations/versions/stk_0004_notifications.py +0 -0
  67. {stkit-17.2.1 → stkit-17.2.3}/stk/notification/__init__.py +0 -0
  68. {stkit-17.2.1 → stkit-17.2.3}/stk/notification/models.py +0 -0
  69. {stkit-17.2.1 → stkit-17.2.3}/stk/notification/views.py +0 -0
  70. {stkit-17.2.1 → stkit-17.2.3}/stk/portal/views.py +0 -0
  71. {stkit-17.2.1 → stkit-17.2.3}/stk/public/__init__.py +0 -0
  72. {stkit-17.2.1 → stkit-17.2.3}/stk/public/models.py +0 -0
  73. {stkit-17.2.1 → stkit-17.2.3}/stk/public/views.py +0 -0
  74. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/__init__.py +0 -0
  75. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/CLAUDE.md +0 -0
  76. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/Dockerfile +0 -0
  77. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/README.md +0 -0
  78. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/__app__/__init__.py +0 -0
  79. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/__app__/app.py +0 -0
  80. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/__app__/settings.py +0 -0
  81. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/claude/skills/stk-api/SKILL.md +0 -0
  82. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +0 -0
  83. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +0 -0
  84. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +0 -0
  85. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +0 -0
  86. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/docker-compose.yml +0 -0
  87. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/dockerignore +0 -0
  88. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/env-sample +0 -0
  89. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/github/workflows/checks.yml +0 -0
  90. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/gitignore +0 -0
  91. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/nginx/nginx.conf +0 -0
  92. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/nginx/stk.conf +0 -0
  93. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/postgres/init-db.sh +0 -0
  94. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/pyproject.toml +0 -0
  95. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/tests/__init__.py +0 -0
  96. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/app/tests/test_app.py +0 -0
  97. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/generator.py +0 -0
  98. {stkit-17.2.1 → stkit-17.2.3}/stk/scaffold/templates.py +0 -0
  99. {stkit-17.2.1 → stkit-17.2.3}/stk/security.py +0 -0
  100. {stkit-17.2.1 → stkit-17.2.3}/stk/settings.py +0 -0
  101. {stkit-17.2.1 → stkit-17.2.3}/stk/shell.py +0 -0
  102. {stkit-17.2.1 → stkit-17.2.3}/stk/static/VERSIONS.txt +0 -0
  103. {stkit-17.2.1 → stkit-17.2.3}/stk/static/css/app.css +0 -0
  104. {stkit-17.2.1 → stkit-17.2.3}/stk/static/css/layout.css +0 -0
  105. {stkit-17.2.1 → stkit-17.2.3}/stk/static/css/vuetify.min.css +0 -0
  106. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.animals.woff2 +0 -0
  107. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.arrows-1.woff2 +0 -0
  108. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.arrows-2.woff2 +0 -0
  109. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.badges.woff2 +0 -0
  110. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.brand-1.woff2 +0 -0
  111. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.brand-2.woff2 +0 -0
  112. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.buildings.woff2 +0 -0
  113. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.charts.woff2 +0 -0
  114. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.communication.woff2 +0 -0
  115. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.computers.woff2 +0 -0
  116. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.core.woff2 +0 -0
  117. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.currencies.woff2 +0 -0
  118. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.database.woff2 +0 -0
  119. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.design-1.woff2 +0 -0
  120. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.design-2.woff2 +0 -0
  121. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.development.woff2 +0 -0
  122. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.devices-1.woff2 +0 -0
  123. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.devices-2.woff2 +0 -0
  124. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.document.woff2 +0 -0
  125. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.e-commerce.woff2 +0 -0
  126. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.electrical.woff2 +0 -0
  127. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.extensions.woff2 +0 -0
  128. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.food.woff2 +0 -0
  129. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.games.woff2 +0 -0
  130. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.gender.woff2 +0 -0
  131. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.gestures.woff2 +0 -0
  132. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.health.woff2 +0 -0
  133. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.laundry.woff2 +0 -0
  134. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.letters.woff2 +0 -0
  135. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.logic.woff2 +0 -0
  136. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.map.woff2 +0 -0
  137. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.math.woff2 +0 -0
  138. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.media.woff2 +0 -0
  139. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.mood.woff2 +0 -0
  140. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.nature.woff2 +0 -0
  141. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.numbers.woff2 +0 -0
  142. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.photography.woff2 +0 -0
  143. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.shapes.woff2 +0 -0
  144. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.sport.woff2 +0 -0
  145. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.symbols.woff2 +0 -0
  146. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.system-1.woff2 +0 -0
  147. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.system-2.woff2 +0 -0
  148. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.system-3.woff2 +0 -0
  149. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.text.woff2 +0 -0
  150. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.vehicles.woff2 +0 -0
  151. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.version-control.woff2 +0 -0
  152. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.weather.woff2 +0 -0
  153. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/fonts/tabler-icons.zodiac.woff2 +0 -0
  154. {stkit-17.2.1 → stkit-17.2.3}/stk/static/icons/tabler-icons.min.css +0 -0
  155. {stkit-17.2.1 → stkit-17.2.3}/stk/static/img/auth-bg.webp +0 -0
  156. {stkit-17.2.1 → stkit-17.2.3}/stk/static/img/favicon.ico +0 -0
  157. {stkit-17.2.1 → stkit-17.2.3}/stk/static/img/stk.svg +0 -0
  158. {stkit-17.2.1 → stkit-17.2.3}/stk/static/js/axios.min.js +0 -0
  159. {stkit-17.2.1 → stkit-17.2.3}/stk/static/js/components/NotificationDropdown.js +0 -0
  160. {stkit-17.2.1 → stkit-17.2.3}/stk/static/js/components/ThemeSwitcher.js +0 -0
  161. {stkit-17.2.1 → stkit-17.2.3}/stk/static/js/components/TransitionExpand.js +0 -0
  162. {stkit-17.2.1 → stkit-17.2.3}/stk/static/js/components/VerticalNavGroup.js +0 -0
  163. {stkit-17.2.1 → stkit-17.2.3}/stk/static/js/components/VerticalNavLink.js +0 -0
  164. {stkit-17.2.1 → stkit-17.2.3}/stk/static/js/components/VerticalNavSectionTitle.js +0 -0
  165. {stkit-17.2.1 → stkit-17.2.3}/stk/static/js/components/index.js +0 -0
  166. {stkit-17.2.1 → stkit-17.2.3}/stk/static/js/config.js +0 -0
  167. {stkit-17.2.1 → stkit-17.2.3}/stk/static/js/vue.min.js +0 -0
  168. {stkit-17.2.1 → stkit-17.2.3}/stk/static/js/vuetify.min.js +0 -0
  169. {stkit-17.2.1 → stkit-17.2.3}/stk/static/robots.txt +0 -0
  170. {stkit-17.2.1 → stkit-17.2.3}/stk/tasks/models.py +0 -0
  171. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/401.html +0 -0
  172. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/404.html +0 -0
  173. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/500.html +0 -0
  174. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/_brand.html +0 -0
  175. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/auth_layout.html +0 -0
  176. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/cms/activities.html +0 -0
  177. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/cms/jobs.html +0 -0
  178. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/cms/roles.html +0 -0
  179. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/cms/users.html +0 -0
  180. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/dashboard.html +0 -0
  181. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/layout.html +0 -0
  182. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/login-layout.html +0 -0
  183. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/mcp/tokens.html +0 -0
  184. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/_macros.html +0 -0
  185. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/_messages.html +0 -0
  186. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/change_password.html +0 -0
  187. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/confirm_email.html +0 -0
  188. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/email/change_notice.html +0 -0
  189. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/email/change_notice.txt +0 -0
  190. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/email/confirmation_instructions.html +0 -0
  191. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/email/confirmation_instructions.txt +0 -0
  192. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/email/login_instructions.html +0 -0
  193. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/email/login_instructions.txt +0 -0
  194. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/email/reset_instructions.html +0 -0
  195. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/email/reset_instructions.txt +0 -0
  196. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/email/reset_notice.html +0 -0
  197. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/email/reset_notice.txt +0 -0
  198. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/email/welcome.html +0 -0
  199. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/email/welcome.txt +0 -0
  200. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/forgot_password.html +0 -0
  201. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/login_user.html +0 -0
  202. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/mf_recovery.html +0 -0
  203. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/mf_recovery_codes.html +0 -0
  204. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/register_user.html +0 -0
  205. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/reset_password.html +0 -0
  206. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/send_confirmation.html +0 -0
  207. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/send_login.html +0 -0
  208. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/two_factor_select.html +0 -0
  209. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/two_factor_setup.html +0 -0
  210. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/two_factor_verify_code.html +0 -0
  211. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/wan_register.html +0 -0
  212. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/wan_signin.html +0 -0
  213. {stkit-17.2.1 → stkit-17.2.3}/stk/templates/security/wan_verify.html +0 -0
  214. {stkit-17.2.1 → stkit-17.2.3}/stk/user/__init__.py +0 -0
  215. {stkit-17.2.1 → stkit-17.2.3}/stk/user/forms.py +0 -0
  216. {stkit-17.2.1 → stkit-17.2.3}/stk/user/models.py +0 -0
  217. {stkit-17.2.1 → stkit-17.2.3}/stk/user/views.py +0 -0
  218. {stkit-17.2.1 → stkit-17.2.3}/stk/utils/email.py +0 -0
  219. {stkit-17.2.1 → stkit-17.2.3}/stk/utils/ratelimit.py +0 -0
  220. {stkit-17.2.1 → stkit-17.2.3}/stk/websocket.py +0 -0
  221. {stkit-17.2.1 → stkit-17.2.3}/tests/test_agent_operability.py +0 -0
  222. {stkit-17.2.1 → stkit-17.2.3}/tests/test_app_index.py +0 -0
  223. {stkit-17.2.1 → stkit-17.2.3}/tests/test_branding.py +0 -0
  224. {stkit-17.2.1 → stkit-17.2.3}/tests/test_builder_ux.py +0 -0
  225. {stkit-17.2.1 → stkit-17.2.3}/tests/test_composition.py +0 -0
  226. {stkit-17.2.1 → stkit-17.2.3}/tests/test_copy_rules.py +0 -0
  227. {stkit-17.2.1 → stkit-17.2.3}/tests/test_csrf_exempt.py +0 -0
  228. {stkit-17.2.1 → stkit-17.2.3}/tests/test_error_handler.py +0 -0
  229. {stkit-17.2.1 → stkit-17.2.3}/tests/test_files.py +0 -0
  230. {stkit-17.2.1 → stkit-17.2.3}/tests/test_migration_drift.py +0 -0
  231. {stkit-17.2.1 → stkit-17.2.3}/tests/test_notifications.py +0 -0
  232. {stkit-17.2.1 → stkit-17.2.3}/tests/test_production_security.py +0 -0
  233. {stkit-17.2.1 → stkit-17.2.3}/tests/test_revision_imports.py +0 -0
  234. {stkit-17.2.1 → stkit-17.2.3}/tests/test_scaffolder.py +0 -0
  235. {stkit-17.2.1 → stkit-17.2.3}/tests/test_security_compatibility.py +0 -0
  236. {stkit-17.2.1 → stkit-17.2.3}/tests/test_shell.py +0 -0
  237. {stkit-17.2.1 → stkit-17.2.3}/tests/test_smoke_pages.py +0 -0
  238. {stkit-17.2.1 → stkit-17.2.3}/tests/test_static_versioning.py +0 -0
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## v17.2.3 (2026-10-31)
4
+
5
+ Run `uv run stk db upgrade`.
6
+
7
+ - MCP is opt-in, like file uploads: add `stk.mcp` to the app's extensions. Without it stk registers no `mcp` blueprint, no `/mcp`, no `/tokens/` page and no Agent Tokens menu entry, so an app can serve its own MCP endpoint. From 17.2.0 to 17.2.2 stk claimed all of them in every app (JSON APIs).
8
+ - stk's MCP token table is `stk_mcp_token`, like every other core table. 17.2.0 to 17.2.2 created `mcp_token`, which failed to migrate in an app that already had a table by that name. Revision `stk_0006` moves stk's own tokens across; an app's own `mcp_token` is recognised by its columns and left alone.
9
+ - `stk.utils.netguard` adds `stream`, which yields the response with its body unread for large downloads and can cap decoded bytes with `max_bytes` or return a redirect with `follow_redirects=False`, and a public `pin(url)` that vets a URL without fetching it, for a client such as a headless browser. `fetch` is built on `stream`. Cookies are never carried across redirect hops (JSON APIs).
10
+
11
+ ### Upgrade considerations
12
+
13
+ - An app that uses stk's MCP tools adds `stk.mcp` to its extensions, or `/mcp` and `/tokens/` return 404. Existing tokens keep working after `stk db upgrade`.
14
+
15
+ ## v17.2.2 (2026-10-31)
16
+
17
+ No migration required.
18
+
19
+ - `stk add-role` and `stk cleanup-sessions` crashed with `TypeError: 'NoneType' object is not callable` because they used the database before the app was built. Every command that runs async work now builds the app first. Run `cleanup-sessions` from cron again if you had stopped it.
20
+ - `stk worker` stops on SIGTERM or Ctrl-C at a safe point: it cancels only the running job, hands it back, and exits without a traceback.
21
+ - The home page describes stk as running on Quart, not Flask.
22
+ - Requires quart-security 2.0.5: login takes the same time whether or not an email has an account, and passkey registration refuses a credential ID another user already holds (Authentication).
23
+
3
24
  ## v17.2.1 (2026-10-31)
4
25
 
5
26
  No migration required.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: stkit
3
- Version: 17.2.1
3
+ Version: 17.2.3
4
4
  Summary: An async Quart framework optimized for AI-assisted development workflows
5
5
  Project-URL: Documentation, https://docs.stkit.dev
6
6
  Project-URL: Source, https://github.com/level09/stk
@@ -28,7 +28,7 @@ Requires-Dist: pillow>=13.0.0
28
28
  Requires-Dist: psycopg2-binary>=2.9.10
29
29
  Requires-Dist: python-dotenv>=1.2.2
30
30
  Requires-Dist: qrcode>=8.2
31
- Requires-Dist: quart-security<3,>=2.0.4
31
+ Requires-Dist: quart-security<3,>=2.0.5
32
32
  Requires-Dist: quart-session>=3.0.0
33
33
  Requires-Dist: quart>=0.20.0
34
34
  Requires-Dist: rich>=14.0.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stkit"
3
- version = "17.2.1"
3
+ version = "17.2.3"
4
4
  description = "An async Quart framework optimized for AI-assisted development workflows"
5
5
  authors = [
6
6
  {name = "Level09", email = "hello@level09.com"}
@@ -30,7 +30,7 @@ dependencies = [
30
30
  "python-dotenv>=1.2.2",
31
31
  "qrcode>=8.2",
32
32
  "quart>=0.20.0",
33
- "quart-security>=2.0.4,<3",
33
+ "quart-security>=2.0.5,<3",
34
34
  "quart-session>=3.0.0",
35
35
  "rich>=14.0.0",
36
36
  "sqlalchemy[asyncio]>=2.0.41",
@@ -3,7 +3,7 @@ stk Framework
3
3
  A modern Python web framework optimized for AI-assisted development.
4
4
  """
5
5
 
6
- __version__ = "17.2.1"
6
+ __version__ = "17.2.3"
7
7
 
8
8
 
9
9
  def __getattr__(name):
@@ -27,7 +27,6 @@ from werkzeug.security import safe_join
27
27
  import stk.extensions as ext
28
28
  from stk.agent_login import agent_login_enabled, bp_agent_login
29
29
  from stk.extensions import session
30
- from stk.mcp.views import bp_mcp
31
30
  from stk.notification.views import bp_notification
32
31
  from stk.portal.views import portal
33
32
  from stk.public.views import public
@@ -209,7 +208,6 @@ def register_blueprints(app):
209
208
  app.register_blueprint(portal)
210
209
  app.register_blueprint(bp_notification)
211
210
  app.register_blueprint(ws_bp)
212
- app.register_blueprint(bp_mcp)
213
211
  if agent_login_enabled(app):
214
212
  app.register_blueprint(bp_agent_login)
215
213
  return None
@@ -300,7 +298,12 @@ def register_navigation(app):
300
298
  app.jinja_env.globals["stk_navigation"] = lambda: [
301
299
  *CORE_NAVIGATION_START,
302
300
  *app.navigation,
303
- *CORE_NAVIGATION_END,
301
+ *(
302
+ entry
303
+ for entry in CORE_NAVIGATION_END
304
+ # Agent Tokens only for apps that opted in with `stk.mcp`.
305
+ if entry.get("to") != "/tokens/" or "stk.mcp" in app.extensions
306
+ ),
304
307
  ]
305
308
  # The app's entries alone, for a layout that draws its own sidebar.
306
309
  app.jinja_env.globals["app_navigation"] = lambda: app.navigation
@@ -68,6 +68,9 @@ def cli_app():
68
68
 
69
69
  def run_async(coro):
70
70
  """Run an async function in sync context (for CLI commands)."""
71
+ # The engine and session factory exist only once the app is built; loading it
72
+ # here means no command can reach the database without it.
73
+ cli_app()
71
74
 
72
75
  async def _wrapper():
73
76
  try:
@@ -1,6 +1,5 @@
1
1
  """Starting an app, serving it locally, and running its durable jobs."""
2
2
 
3
- import asyncio
4
3
  from pathlib import Path
5
4
 
6
5
  import click
@@ -82,7 +81,5 @@ def worker(timeout):
82
81
  await work(timeout)
83
82
 
84
83
  console.print("[green]Worker started[/]")
85
- try:
86
- run_async(run())
87
- except (KeyboardInterrupt, asyncio.CancelledError):
88
- console.print("Worker stopped")
84
+ run_async(run())
85
+ console.print("Worker stopped")
@@ -1,5 +1,8 @@
1
1
  """Expose an app's own functions to AI agents over MCP at `/mcp`.
2
2
 
3
+ Opt in with `extensions=[stk.mcp, ...]`; without it there is no `/mcp`, no
4
+ `/tokens/` page, and an app is free to serve its own MCP endpoint.
5
+
3
6
  A tool is an async function taking the validated arguments dict. It runs as the
4
7
  token's user: `current_user` and `g.db_session` work as in a request handler.
5
8
  """
@@ -51,3 +54,10 @@ def tool(name, description, schema, *, role=None):
51
54
  return fn
52
55
 
53
56
  return decorator
57
+
58
+
59
+ def init_app(app):
60
+ from stk.mcp.views import bp_mcp
61
+
62
+ app.extensions["stk.mcp"] = True
63
+ app.register_blueprint(bp_mcp)
@@ -13,7 +13,7 @@ LIFETIME = timedelta(days=90)
13
13
 
14
14
 
15
15
  class McpToken(Base):
16
- __tablename__ = "mcp_token"
16
+ __tablename__ = "stk_mcp_token"
17
17
 
18
18
  id = Column(Integer, primary_key=True)
19
19
  user_id = Column(
@@ -1,4 +1,7 @@
1
- """MCP bearer tokens: one row per token, storing only its sha256."""
1
+ """MCP bearer tokens: one row per token, storing only its sha256.
2
+
3
+ Until 17.2.3 this created `mcp_token`, a name an app may already use for its own
4
+ table; stk_0006 moves such an stk table to `stk_mcp_token`."""
2
5
 
3
6
  import sqlalchemy as sa
4
7
  from alembic import op
@@ -11,7 +14,7 @@ depends_on = None
11
14
 
12
15
  def upgrade() -> None:
13
16
  op.create_table(
14
- "mcp_token",
17
+ "stk_mcp_token",
15
18
  sa.Column("id", sa.Integer(), nullable=False),
16
19
  sa.Column("user_id", sa.Integer(), nullable=False),
17
20
  sa.Column("name", sa.String(length=80), nullable=False),
@@ -24,19 +27,19 @@ def upgrade() -> None:
24
27
  sa.ForeignKeyConstraint(
25
28
  ["user_id"],
26
29
  ["user.id"],
27
- name=op.f("fk_mcp_token_user_id"),
30
+ name=op.f("fk_stk_mcp_token_user_id"),
28
31
  ondelete="CASCADE",
29
32
  ),
30
- sa.PrimaryKeyConstraint("id", name=op.f("pk_mcp_token")),
31
- sa.UniqueConstraint("token_hash", name=op.f("uq_mcp_token_token_hash")),
33
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_stk_mcp_token")),
34
+ sa.UniqueConstraint("token_hash", name=op.f("uq_stk_mcp_token_token_hash")),
32
35
  )
33
- with op.batch_alter_table("mcp_token", schema=None) as batch_op:
36
+ with op.batch_alter_table("stk_mcp_token", schema=None) as batch_op:
34
37
  batch_op.create_index(
35
- batch_op.f("ix_mcp_token_user_id"), ["user_id"], unique=False
38
+ batch_op.f("ix_stk_mcp_token_user_id"), ["user_id"], unique=False
36
39
  )
37
40
 
38
41
 
39
42
  def downgrade() -> None:
40
- with op.batch_alter_table("mcp_token", schema=None) as batch_op:
41
- batch_op.drop_index(batch_op.f("ix_mcp_token_user_id"))
42
- op.drop_table("mcp_token")
43
+ with op.batch_alter_table("stk_mcp_token", schema=None) as batch_op:
44
+ batch_op.drop_index(batch_op.f("ix_stk_mcp_token_user_id"))
45
+ op.drop_table("stk_mcp_token")
@@ -0,0 +1,69 @@
1
+ """Move stk's MCP tokens from `mcp_token` to `stk_mcp_token`.
2
+
3
+ stk 17.2.0 to 17.2.2 created `mcp_token`, a name an app may already use for its
4
+ own table. stk_0005 now creates `stk_mcp_token`; a database that ran the old
5
+ stk_0005 has stk's tokens in `mcp_token` instead. Only that table moves: it is
6
+ recognised by stk's `user_uniquifier` column, so an app's own `mcp_token` stays.
7
+ """
8
+
9
+ import sqlalchemy as sa
10
+ from alembic import op
11
+
12
+ revision = "stk_0006"
13
+ down_revision = "stk_0005"
14
+ branch_labels = None
15
+ depends_on = None
16
+
17
+ COLUMNS = (
18
+ "id, user_id, name, token_hash, user_uniquifier, scopes, created_at,"
19
+ " expires_at, last_used_at"
20
+ )
21
+
22
+
23
+ def upgrade() -> None:
24
+ inspector = sa.inspect(op.get_bind())
25
+ tables = set(inspector.get_table_names())
26
+ if "mcp_token" not in tables:
27
+ return
28
+ legacy = {c["name"] for c in inspector.get_columns("mcp_token")}
29
+ if "user_uniquifier" not in legacy or "token_hash" not in legacy:
30
+ return
31
+ if "stk_mcp_token" not in tables:
32
+ # The database ran the old stk_0005, so the new one never created this.
33
+ _create_stk_mcp_token()
34
+ op.execute(f"INSERT INTO stk_mcp_token ({COLUMNS}) SELECT {COLUMNS} FROM mcp_token")
35
+ # destructive: every row was copied to stk_mcp_token on the line above.
36
+ op.drop_table("mcp_token")
37
+
38
+
39
+ def _create_stk_mcp_token():
40
+ # Same table as stk_0005 creates; revision modules cannot import each other.
41
+ op.create_table(
42
+ "stk_mcp_token",
43
+ sa.Column("id", sa.Integer(), nullable=False),
44
+ sa.Column("user_id", sa.Integer(), nullable=False),
45
+ sa.Column("name", sa.String(length=80), nullable=False),
46
+ sa.Column("token_hash", sa.String(length=64), nullable=False),
47
+ sa.Column("user_uniquifier", sa.String(length=255), nullable=False),
48
+ sa.Column("scopes", sa.JSON(), nullable=False),
49
+ sa.Column("created_at", sa.DateTime(), nullable=False),
50
+ sa.Column("expires_at", sa.DateTime(), nullable=False),
51
+ sa.Column("last_used_at", sa.DateTime(), nullable=True),
52
+ sa.ForeignKeyConstraint(
53
+ ["user_id"],
54
+ ["user.id"],
55
+ name=op.f("fk_stk_mcp_token_user_id"),
56
+ ondelete="CASCADE",
57
+ ),
58
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_stk_mcp_token")),
59
+ sa.UniqueConstraint("token_hash", name=op.f("uq_stk_mcp_token_token_hash")),
60
+ )
61
+ with op.batch_alter_table("stk_mcp_token", schema=None) as batch_op:
62
+ batch_op.create_index(
63
+ batch_op.f("ix_stk_mcp_token_user_id"), ["user_id"], unique=False
64
+ )
65
+
66
+
67
+ def downgrade() -> None:
68
+ # The old name collided with apps' own tables; there is nothing to restore.
69
+ pass
@@ -25,6 +25,7 @@ from stk.tasks.models import Job
25
25
  logger = logging.getLogger(__name__)
26
26
 
27
27
  JOBS = {}
28
+ _running = None # the job task `stk worker` is awaiting, for a clean shutdown
28
29
  TIMEOUT = 3600
29
30
  POLL_SECONDS = 2
30
31
  RECOVER_SECONDS = 60
@@ -164,8 +165,13 @@ async def run_next(timeout=TIMEOUT):
164
165
  job_id, name, payload, attempts, max_attempts = claimed
165
166
  async with ext.async_session_factory() as session:
166
167
  try:
167
- async with asyncio.timeout(timeout):
168
- await JOBS[name](session, **payload)
168
+ global _running
169
+ _running = asyncio.create_task(JOBS[name](session, **payload))
170
+ try:
171
+ # wait_for cancels the job itself on timeout or when this is cancelled.
172
+ await asyncio.wait_for(_running, timeout)
173
+ finally:
174
+ _running = None
169
175
  done = await session.execute(
170
176
  _owned(job_id, lease).values(
171
177
  status="done", locked_until=None, last_error=None
@@ -216,11 +222,28 @@ async def recover():
216
222
 
217
223
  async def work(timeout=TIMEOUT):
218
224
  """Run due jobs one at a time until SIGTERM or Ctrl-C."""
219
- asyncio.get_running_loop().add_signal_handler(
220
- signal.SIGTERM, asyncio.current_task().cancel
221
- )
225
+ stop = asyncio.Event()
226
+
227
+ def shutdown():
228
+ # Cancel only the job, never the worker mid-query: it hands the job back
229
+ # and the loop ends at its next check.
230
+ stop.set()
231
+ if _running is not None:
232
+ _running.cancel()
233
+
234
+ loop = asyncio.get_running_loop()
235
+ for sig in (signal.SIGTERM, signal.SIGINT):
236
+ loop.add_signal_handler(sig, shutdown)
237
+ try:
238
+ await _drain(timeout, stop)
239
+ except asyncio.CancelledError:
240
+ if not stop.is_set():
241
+ raise
242
+
243
+
244
+ async def _drain(timeout, stop):
222
245
  recovered = None
223
- while True:
246
+ while not stop.is_set():
224
247
  # Also between jobs, so a queue that never empties still recovers.
225
248
  if recovered is None or datetime.now() - recovered >= timedelta(
226
249
  seconds=RECOVER_SECONDS
@@ -228,4 +251,7 @@ async def work(timeout=TIMEOUT):
228
251
  await recover()
229
252
  recovered = datetime.now()
230
253
  if not await run_next(timeout):
231
- await asyncio.sleep(POLL_SECONDS)
254
+ try:
255
+ await asyncio.wait_for(stop.wait(), POLL_SECONDS)
256
+ except TimeoutError:
257
+ pass
@@ -8,7 +8,7 @@
8
8
 
9
9
  <h1 class="text-headline-large font-weight-bold mb-3">Build faster with stk</h1>
10
10
  <p class="text-body-large text-medium-emphasis mb-8">
11
- A modern Flask framework for building web applications.
11
+ An async Python framework for building web applications, on Quart.
12
12
  </p>
13
13
 
14
14
  {% if current_user.is_authenticated %}
@@ -6,6 +6,8 @@ address between the check and the connection. Every redirect hop is re-checked."
6
6
  import asyncio
7
7
  import ipaddress
8
8
  import socket
9
+ from collections.abc import AsyncIterator
10
+ from contextlib import asynccontextmanager
9
11
  from urllib.parse import urljoin, urlsplit, urlunsplit
10
12
 
11
13
  import httpx2
@@ -35,7 +37,7 @@ def _public(addr: str) -> bool:
35
37
 
36
38
 
37
39
  def _origin(url: str) -> tuple:
38
- """(scheme, host, port) with the default port filled in; `_pin` rejects bad ones."""
40
+ """(scheme, host, port) with the default port filled in; `pin` rejects bad ones."""
39
41
  parts = urlsplit(url)
40
42
  try:
41
43
  port = parts.port
@@ -48,8 +50,14 @@ def _origin(url: str) -> tuple:
48
50
  )
49
51
 
50
52
 
51
- async def _pin(url: str) -> tuple[str, str, str]:
52
- """Return (url with the checked IP, Host header, TLS server name)."""
53
+ async def pin(url: str) -> tuple[str, str, str]:
54
+ """Check a URL and return (url with the checked IP, Host header, TLS server name).
55
+
56
+ Raise Blocked unless it is http(s), carries no credentials, and every address
57
+ its host resolves to is public. Connect to the returned URL, send the Host
58
+ header, and use the server name for SNI, so the connection goes to the
59
+ address that was checked. Use it alone to vet a URL another client (a
60
+ headless browser) will open."""
53
61
  parts = urlsplit(url)
54
62
  host = parts.hostname
55
63
  if parts.scheme not in ("http", "https") or not host:
@@ -84,21 +92,45 @@ async def _pin(url: str) -> tuple[str, str, str]:
84
92
  return target, host_header, host
85
93
 
86
94
 
87
- async def fetch(
95
+ def _capped(response: httpx2.Response, max_bytes: int) -> None:
96
+ """Make every read of the body (aread, aiter_bytes/text/lines, .content after
97
+ aread) raise Blocked past `max_bytes` decoded bytes."""
98
+ if int(response.headers.get("content-length") or 0) > max_bytes:
99
+ raise Blocked(f"response is larger than {max_bytes} bytes")
100
+ read = response.aiter_bytes
101
+
102
+ async def aiter_bytes(chunk_size=None):
103
+ seen = 0
104
+ async for chunk in read(chunk_size):
105
+ seen += len(chunk)
106
+ if seen > max_bytes:
107
+ raise Blocked(f"response is larger than {max_bytes} bytes")
108
+ yield chunk
109
+
110
+ # aread, aiter_text and aiter_lines all read through self.aiter_bytes.
111
+ response.aiter_bytes = aiter_bytes
112
+
113
+
114
+ @asynccontextmanager
115
+ async def stream(
88
116
  url: str,
89
117
  *,
90
118
  headers: dict | None = None,
91
119
  timeout: float = 10,
92
- max_bytes: int = 1_048_576,
120
+ max_bytes: int | None = None,
93
121
  max_redirects: int = 5,
122
+ follow_redirects: bool = True,
94
123
  transport: httpx2.AsyncBaseTransport | None = None,
95
- ) -> tuple[httpx2.Response, str]:
96
- """GET a user-supplied URL. Return (response with its body read, final url).
97
-
98
- Raise Blocked when the URL, any resolved address, a redirect or the decoded
99
- body size is refused, TimeoutError when the whole fetch outlasts `timeout`,
100
- and httpx2.HTTPError for transport failures. Environment proxies are ignored:
101
- a proxy would resolve the name itself and undo the pin."""
124
+ ) -> AsyncIterator[tuple[httpx2.Response, str]]:
125
+ """GET a user-supplied URL; yield (response with its body unread, final url).
126
+
127
+ `timeout` bounds the whole fetch, including reading the body inside the
128
+ block. With `max_bytes`, reading more decoded bytes than that raises
129
+ Blocked. With `follow_redirects=False` a redirect is yielded as is. Raise
130
+ Blocked when the URL, an address or a redirect is refused or there are more
131
+ than `max_redirects`, TimeoutError past `timeout`, and httpx2.HTTPError for
132
+ transport failures. Environment proxies are ignored: a proxy would resolve
133
+ the name itself and undo the pin."""
102
134
  headers = dict(headers or {})
103
135
  origin = _origin(url)
104
136
  async with (
@@ -108,7 +140,7 @@ async def fetch(
108
140
  ) as client,
109
141
  ):
110
142
  for _ in range(max_redirects + 1):
111
- target, host_header, server_name = await _pin(url)
143
+ target, host_header, server_name = await pin(url)
112
144
  if _origin(url) != origin:
113
145
  # Never hand the caller's credentials to an origin it did not name,
114
146
  # including the same host over plain HTTP or another port.
@@ -128,19 +160,41 @@ async def fetch(
128
160
  # them across hops.
129
161
  client.cookies.clear()
130
162
  try:
131
- if response.has_redirect_location:
163
+ if follow_redirects and response.has_redirect_location:
132
164
  url = urljoin(url, response.headers["location"])
133
165
  continue
134
- if int(response.headers.get("content-length") or 0) > max_bytes:
135
- raise Blocked(f"response is larger than {max_bytes} bytes")
136
- body = bytearray()
137
- async for chunk in response.aiter_bytes():
138
- body += chunk
139
- if len(body) > max_bytes:
140
- raise Blocked(f"response is larger than {max_bytes} bytes")
141
- # What aread() stores; .content, .text and .json() then work.
142
- response._content = bytes(body)
143
- return response, url
166
+ if max_bytes is not None:
167
+ _capped(response, max_bytes)
168
+ yield response, url
169
+ return
144
170
  finally:
145
171
  await response.aclose()
146
172
  raise Blocked("too many redirects")
173
+
174
+
175
+ async def fetch(
176
+ url: str,
177
+ *,
178
+ headers: dict | None = None,
179
+ timeout: float = 10,
180
+ max_bytes: int = 1_048_576,
181
+ max_redirects: int = 5,
182
+ transport: httpx2.AsyncBaseTransport | None = None,
183
+ ) -> tuple[httpx2.Response, str]:
184
+ """GET a user-supplied URL. Return (response with its body read, final url).
185
+
186
+ Same checks and errors as `stream`, with the body read and capped at
187
+ `max_bytes` decoded bytes (default 1 MiB)."""
188
+ async with stream(
189
+ url,
190
+ headers=headers,
191
+ timeout=timeout,
192
+ max_bytes=max_bytes,
193
+ max_redirects=max_redirects,
194
+ transport=transport,
195
+ ) as (response, final_url):
196
+ # A body the transport already loaded (a test's MockTransport) skips the
197
+ # streaming guard; check what was read.
198
+ if len(await response.aread()) > max_bytes:
199
+ raise Blocked(f"response is larger than {max_bytes} bytes")
200
+ return response, final_url
@@ -61,3 +61,20 @@ class DowngradeArgumentTest(unittest.TestCase):
61
61
  result = CliRunner().invoke(main, ["db", "downgrade", "-1"])
62
62
  self.assertEqual(result.exit_code, 0, result.output)
63
63
  self.assertEqual(run.call_args.args[-1], "-1")
64
+
65
+
66
+ class DatabaseCommandTest(unittest.TestCase):
67
+ def test_run_async_builds_the_app_before_touching_the_database(self):
68
+ """`add-role` and `cleanup-sessions` crashed: nothing had built the engine."""
69
+ with (
70
+ patch("stk.cli.base.cli_app") as load,
71
+ patch("stk.tasks.cleanup_expired_sessions") as cleanup,
72
+ ):
73
+ cleanup.side_effect = lambda: _done()
74
+ result = CliRunner().invoke(main, ["cleanup-sessions"])
75
+ self.assertEqual(result.exit_code, 0, result.output)
76
+ load.assert_called()
77
+
78
+
79
+ async def _done():
80
+ return None
@@ -1,6 +1,8 @@
1
1
  """Durable jobs: enqueue, claim, retry, failure, and recovery of a dead worker's job."""
2
2
 
3
3
  import asyncio
4
+ import os
5
+ import signal
4
6
  import unittest
5
7
  from datetime import datetime, timedelta
6
8
 
@@ -11,7 +13,7 @@ from sqlalchemy import select, update
11
13
  import stk.extensions as ext
12
14
  from stk.app import Stk
13
15
  from stk.settings import TestingConfig
14
- from stk.tasks import enqueue, job, recover, run_next
16
+ from stk.tasks import enqueue, job, recover, run_next, work
15
17
  from stk.tasks.models import Job
16
18
  from stk.user.models import Activity, Base, User
17
19
  from stk.user.views import api_jobs
@@ -163,6 +165,20 @@ class JobTests(unittest.IsolatedAsyncioTestCase):
163
165
  self.assertEqual(row.status, "running")
164
166
  self.assertNotIn("lost race", await self.activities())
165
167
 
168
+ async def test_sigterm_hands_back_the_running_job_and_stops_cleanly(self):
169
+ job_id = await enqueue("jobs_test_slow")
170
+ worker = asyncio.create_task(work())
171
+ for _ in range(50):
172
+ await asyncio.sleep(0.05)
173
+ if (await self.get(job_id)).status == "running":
174
+ break
175
+ os.kill(os.getpid(), signal.SIGTERM)
176
+ await asyncio.wait_for(worker, 5) # returns, not CancelledError
177
+ row = await self.get(job_id)
178
+ self.assertEqual(
179
+ (row.status, row.attempts, row.locked_until), ("queued", 0, None)
180
+ )
181
+
166
182
  async def test_recovers_job_left_running_by_a_dead_worker(self):
167
183
  lost = await enqueue("jobs_test_record", user_id=self.user_id, action="lost")
168
184
  spent = await enqueue("jobs_test_record", user_id=self.user_id, action="spent")
@@ -9,6 +9,7 @@ from quart_security import current_user, hash_password
9
9
  from sqlalchemy import func, select
10
10
 
11
11
  import stk.extensions as ext
12
+ import stk.mcp
12
13
  from stk.app import Stk
13
14
  from stk.mcp import ToolError, tool
14
15
  from stk.mcp.models import McpToken
@@ -53,7 +54,7 @@ def rpc(method, params=None, rid=1):
53
54
 
54
55
  class McpTests(unittest.IsolatedAsyncioTestCase):
55
56
  async def asyncSetUp(self):
56
- self.app = Stk(__name__, config=McpConfig)
57
+ self.app = Stk(__name__, config=McpConfig, extensions=[stk.mcp])
57
58
  async with ext.engine.begin() as conn:
58
59
  await conn.run_sync(Base.metadata.create_all)
59
60
  async with ext.async_session_factory() as db:
@@ -305,5 +306,19 @@ class McpTests(unittest.IsolatedAsyncioTestCase):
305
306
  tool("test.array", "Not an object.", {"type": "array"})
306
307
 
307
308
 
309
+ class McpOptInTests(unittest.IsolatedAsyncioTestCase):
310
+ async def test_an_app_without_stk_mcp_has_no_mcp_routes_or_menu(self):
311
+ """An app may serve its own /mcp; stk must not claim it uninvited."""
312
+ app = Stk(__name__, config=McpConfig)
313
+ self.assertNotIn("mcp", app.blueprints)
314
+ self.assertNotIn("/mcp", {r.rule for r in app.url_map.iter_rules()})
315
+ menu = [e.get("to") for e in app.jinja_env.globals["stk_navigation"]()]
316
+ self.assertNotIn("/tokens/", menu)
317
+ with_mcp = Stk(__name__, config=McpConfig, extensions=[stk.mcp])
318
+ menu = [e.get("to") for e in with_mcp.jinja_env.globals["stk_navigation"]()]
319
+ self.assertIn("/tokens/", menu)
320
+ await ext.engine.dispose()
321
+
322
+
308
323
  if __name__ == "__main__":
309
324
  unittest.main()
@@ -0,0 +1,62 @@
1
+ """stk_0006 moves stk's own `mcp_token` to `stk_mcp_token` and leaves an app's alone."""
2
+
3
+ import sqlite3
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+ from alembic import command
9
+
10
+ from stk.migrations import build_alembic_config
11
+
12
+ LEGACY_TOKEN = (
13
+ "INSERT INTO mcp_token (id, user_id, name, token_hash, user_uniquifier,"
14
+ " scopes, created_at, expires_at) VALUES (1, 1, 'agent', 'h', 'u', '[]',"
15
+ " '2026-10-31', '2027-01-29')"
16
+ )
17
+
18
+
19
+ class McpTokenTableMigrationTest(unittest.TestCase):
20
+ def setUp(self):
21
+ self.dir = tempfile.TemporaryDirectory()
22
+ self.path = Path(self.dir.name) / "db.sqlite"
23
+ self.config = build_alembic_config(url=f"sqlite:///{self.path}", quiet=True)
24
+
25
+ def tearDown(self):
26
+ self.dir.cleanup()
27
+
28
+ def sql(self, statement):
29
+ with sqlite3.connect(self.path) as db:
30
+ return db.execute(statement).fetchall()
31
+
32
+ def tables(self):
33
+ return {
34
+ r[0] for r in self.sql("select name from sqlite_master where type='table'")
35
+ }
36
+
37
+ def test_fresh_database_gets_only_the_prefixed_table(self):
38
+ command.upgrade(self.config, "heads")
39
+ self.assertIn("stk_mcp_token", self.tables())
40
+ self.assertNotIn("mcp_token", self.tables())
41
+
42
+ def test_tokens_from_the_old_table_name_move_over(self):
43
+ # A database that ran stk_0005 from 17.2.0 to 17.2.2.
44
+ command.upgrade(self.config, "stk_0004")
45
+ self.sql(
46
+ "CREATE TABLE mcp_token (id INTEGER PRIMARY KEY, user_id INTEGER, name TEXT,"
47
+ " token_hash TEXT, user_uniquifier TEXT, scopes JSON, created_at DATETIME,"
48
+ " expires_at DATETIME, last_used_at DATETIME)"
49
+ )
50
+ self.sql(LEGACY_TOKEN)
51
+ self.sql("UPDATE alembic_version SET version_num = 'stk_0005'")
52
+ command.upgrade(self.config, "heads")
53
+ self.assertNotIn("mcp_token", self.tables())
54
+ self.assertEqual(self.sql("select name from stk_mcp_token"), [("agent",)])
55
+
56
+ def test_an_apps_own_mcp_token_table_is_left_alone(self):
57
+ command.upgrade(self.config, "stk_0004")
58
+ self.sql("CREATE TABLE mcp_token (id INTEGER PRIMARY KEY, grant_id TEXT)")
59
+ self.sql("INSERT INTO mcp_token (id, grant_id) VALUES (7, 'g')")
60
+ command.upgrade(self.config, "heads")
61
+ self.assertEqual(self.sql("select id, grant_id from mcp_token"), [(7, "g")])
62
+ self.assertIn("stk_mcp_token", self.tables())