secondshift 0.1.0b12__py3-none-any.whl

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 (371) hide show
  1. secondshift/__init__.py +1 -0
  2. secondshift/__main__.py +3 -0
  3. secondshift/cli.py +17 -0
  4. secondshift/config/__init__.py +0 -0
  5. secondshift/config/api.py +23 -0
  6. secondshift/config/app.py +62 -0
  7. secondshift/config/constants.py +279 -0
  8. secondshift/config/container_env.py +118 -0
  9. secondshift/config/first_run.py +40 -0
  10. secondshift/config/formatting.py +5 -0
  11. secondshift/config/gunicorn.py +31 -0
  12. secondshift/config/logging.py +40 -0
  13. secondshift/config/settings/__init__.py +0 -0
  14. secondshift/config/settings/base.py +524 -0
  15. secondshift/config/settings/dev.py +14 -0
  16. secondshift/config/settings/local.py +23 -0
  17. secondshift/config/settings/production.py +113 -0
  18. secondshift/config/settings/runner.py +140 -0
  19. secondshift/config/settings/test.py +47 -0
  20. secondshift/config/static_storage.py +45 -0
  21. secondshift/config/urls.py +42 -0
  22. secondshift/connections/__init__.py +0 -0
  23. secondshift/connections/access.py +27 -0
  24. secondshift/connections/admin.py +25 -0
  25. secondshift/connections/agents.py +224 -0
  26. secondshift/connections/api.py +747 -0
  27. secondshift/connections/apps.py +6 -0
  28. secondshift/connections/catalog.py +96 -0
  29. secondshift/connections/checks.py +22 -0
  30. secondshift/connections/dao.py +305 -0
  31. secondshift/connections/errors.py +13 -0
  32. secondshift/connections/execution.py +262 -0
  33. secondshift/connections/external.py +258 -0
  34. secondshift/connections/github.py +123 -0
  35. secondshift/connections/http_api/__init__.py +1 -0
  36. secondshift/connections/http_api/client.py +161 -0
  37. secondshift/connections/http_api/connections.py +187 -0
  38. secondshift/connections/http_api/spec.py +382 -0
  39. secondshift/connections/mcp/__init__.py +1 -0
  40. secondshift/connections/mcp/base.py +139 -0
  41. secondshift/connections/mcp/client.py +353 -0
  42. secondshift/connections/mcp/custom.py +86 -0
  43. secondshift/connections/mcp/github.py +256 -0
  44. secondshift/connections/mcp/oauth.py +352 -0
  45. secondshift/connections/mcp/providers.py +15 -0
  46. secondshift/connections/mcp/schemas.py +77 -0
  47. secondshift/connections/mcp/sentry.py +135 -0
  48. secondshift/connections/mcp/signin.py +206 -0
  49. secondshift/connections/migration_helpers.py +28 -0
  50. secondshift/connections/migrations/0001_initial.py +101 -0
  51. secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
  52. secondshift/connections/migrations/0003_require_approval.py +23 -0
  53. secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
  54. secondshift/connections/migrations/0005_agentconnection.py +36 -0
  55. secondshift/connections/migrations/0006_remove_is_default.py +23 -0
  56. secondshift/connections/migrations/0007_agent_last_check.py +18 -0
  57. secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
  58. secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
  59. secondshift/connections/migrations/0010_connection_attribution.py +18 -0
  60. secondshift/connections/migrations/__init__.py +0 -0
  61. secondshift/connections/model_providers.py +441 -0
  62. secondshift/connections/models.py +246 -0
  63. secondshift/connections/outbound.py +317 -0
  64. secondshift/connections/repository_ref.py +19 -0
  65. secondshift/connections/schemas.py +313 -0
  66. secondshift/connections/serialization.py +100 -0
  67. secondshift/connections/services.py +94 -0
  68. secondshift/connections/tools.py +38 -0
  69. secondshift/connections/webhooks.py +107 -0
  70. secondshift/core/__init__.py +0 -0
  71. secondshift/core/adapters.py +38 -0
  72. secondshift/core/admin.py +5 -0
  73. secondshift/core/api.py +104 -0
  74. secondshift/core/apps.py +20 -0
  75. secondshift/core/launcher.py +311 -0
  76. secondshift/core/local_agents.py +90 -0
  77. secondshift/core/local_output.py +120 -0
  78. secondshift/core/management/__init__.py +0 -0
  79. secondshift/core/management/commands/__init__.py +0 -0
  80. secondshift/core/management/commands/bootstrap_dev.py +33 -0
  81. secondshift/core/management/commands/dev_reset_db.py +204 -0
  82. secondshift/core/management/commands/login.py +23 -0
  83. secondshift/core/management/commands/logs.py +58 -0
  84. secondshift/core/management/commands/status.py +46 -0
  85. secondshift/core/management/commands/up.py +244 -0
  86. secondshift/core/middleware.py +21 -0
  87. secondshift/core/migrations/__init__.py +0 -0
  88. secondshift/core/schemas.py +71 -0
  89. secondshift/core/signin.py +58 -0
  90. secondshift/core/static/core/account.css +149 -0
  91. secondshift/core/static/core/admin.css +135 -0
  92. secondshift/core/templatetags/__init__.py +0 -0
  93. secondshift/core/templatetags/accounts.py +20 -0
  94. secondshift/core/terminal.py +27 -0
  95. secondshift/core/views.py +10 -0
  96. secondshift/gateway/__init__.py +0 -0
  97. secondshift/gateway/apps.py +6 -0
  98. secondshift/gateway/dao.py +137 -0
  99. secondshift/gateway/metering.py +171 -0
  100. secondshift/gateway/migrations/0001_initial.py +54 -0
  101. secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
  102. secondshift/gateway/migrations/__init__.py +0 -0
  103. secondshift/gateway/models.py +61 -0
  104. secondshift/gateway/pricing.py +39 -0
  105. secondshift/gateway/proxy.py +369 -0
  106. secondshift/gateway/tokens.py +68 -0
  107. secondshift/gateway/urls.py +17 -0
  108. secondshift/gateway/views.py +11 -0
  109. secondshift/github/__init__.py +6 -0
  110. secondshift/github/app.py +109 -0
  111. secondshift/github/authors.py +56 -0
  112. secondshift/github/credentials.py +59 -0
  113. secondshift/github/errors.py +2 -0
  114. secondshift/github/git.py +56 -0
  115. secondshift/github/identity.py +35 -0
  116. secondshift/github/installations.py +74 -0
  117. secondshift/github/issues.py +75 -0
  118. secondshift/github/permissions.py +49 -0
  119. secondshift/github/pulls.py +49 -0
  120. secondshift/github/repos.py +52 -0
  121. secondshift/github/rest.py +93 -0
  122. secondshift/github/ruff.toml +16 -0
  123. secondshift/github/tokens.py +52 -0
  124. secondshift/jobs/__init__.py +0 -0
  125. secondshift/jobs/access.py +12 -0
  126. secondshift/jobs/admin.py +83 -0
  127. secondshift/jobs/api.py +184 -0
  128. secondshift/jobs/apps.py +6 -0
  129. secondshift/jobs/automatic_grants.py +115 -0
  130. secondshift/jobs/autosync.py +78 -0
  131. secondshift/jobs/capabilities.py +43 -0
  132. secondshift/jobs/dao.py +258 -0
  133. secondshift/jobs/drafts.py +112 -0
  134. secondshift/jobs/grants.py +116 -0
  135. secondshift/jobs/limits.py +26 -0
  136. secondshift/jobs/management/__init__.py +0 -0
  137. secondshift/jobs/management/commands/__init__.py +0 -0
  138. secondshift/jobs/manifest.py +364 -0
  139. secondshift/jobs/migrations/0001_initial.py +87 -0
  140. secondshift/jobs/migrations/0002_job_history.py +18 -0
  141. secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
  142. secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
  143. secondshift/jobs/migrations/0005_job_triggers.py +18 -0
  144. secondshift/jobs/migrations/0006_job_webhook.py +18 -0
  145. secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
  146. secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
  147. secondshift/jobs/migrations/0009_job_subagents.py +18 -0
  148. secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
  149. secondshift/jobs/migrations/__init__.py +0 -0
  150. secondshift/jobs/models.py +199 -0
  151. secondshift/jobs/needs.py +154 -0
  152. secondshift/jobs/plan.py +176 -0
  153. secondshift/jobs/planning.py +386 -0
  154. secondshift/jobs/readiness.py +46 -0
  155. secondshift/jobs/repository_sync.py +199 -0
  156. secondshift/jobs/scheduling.py +26 -0
  157. secondshift/jobs/schemas.py +207 -0
  158. secondshift/jobs/serialization.py +218 -0
  159. secondshift/jobs/skill_needs.py +189 -0
  160. secondshift/jobs/sync.py +252 -0
  161. secondshift/jobs/tool_policy.py +82 -0
  162. secondshift/jobs/tool_rules.py +200 -0
  163. secondshift/mcp_server/__init__.py +6 -0
  164. secondshift/mcp_server/api.py +79 -0
  165. secondshift/mcp_server/apps.py +7 -0
  166. secondshift/mcp_server/audit.py +22 -0
  167. secondshift/mcp_server/core.py +12 -0
  168. secondshift/mcp_server/dao.py +23 -0
  169. secondshift/mcp_server/migrations/0001_initial.py +33 -0
  170. secondshift/mcp_server/migrations/__init__.py +0 -0
  171. secondshift/mcp_server/models.py +40 -0
  172. secondshift/mcp_server/schemas.py +204 -0
  173. secondshift/mcp_server/tools/__init__.py +9 -0
  174. secondshift/mcp_server/tools/jobs.py +106 -0
  175. secondshift/mcp_server/tools/manifests.py +127 -0
  176. secondshift/mcp_server/tools/repositories.py +42 -0
  177. secondshift/mcp_server/tools/runs.py +86 -0
  178. secondshift/mcp_server/tools/tasks.py +103 -0
  179. secondshift/mcp_server/tools_registry.py +81 -0
  180. secondshift/repositories/__init__.py +0 -0
  181. secondshift/repositories/access.py +13 -0
  182. secondshift/repositories/admin.py +19 -0
  183. secondshift/repositories/api.py +296 -0
  184. secondshift/repositories/apps.py +6 -0
  185. secondshift/repositories/dao.py +114 -0
  186. secondshift/repositories/external_skills.py +224 -0
  187. secondshift/repositories/github.py +27 -0
  188. secondshift/repositories/github_access.py +57 -0
  189. secondshift/repositories/github_check.py +279 -0
  190. secondshift/repositories/github_sync.py +181 -0
  191. secondshift/repositories/github_views.py +90 -0
  192. secondshift/repositories/local.py +198 -0
  193. secondshift/repositories/management/__init__.py +0 -0
  194. secondshift/repositories/management/commands/__init__.py +0 -0
  195. secondshift/repositories/management/commands/prune_source_cache.py +15 -0
  196. secondshift/repositories/migrations/0001_initial.py +45 -0
  197. secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
  198. secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
  199. secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
  200. secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
  201. secondshift/repositories/migrations/0006_allow_issues.py +18 -0
  202. secondshift/repositories/migrations/__init__.py +0 -0
  203. secondshift/repositories/mirrors.py +50 -0
  204. secondshift/repositories/models.py +104 -0
  205. secondshift/repositories/pull_requests.py +199 -0
  206. secondshift/repositories/schemas.py +102 -0
  207. secondshift/repositories/serialization.py +133 -0
  208. secondshift/repositories/skill_source.py +64 -0
  209. secondshift/repositories/snapshots.py +238 -0
  210. secondshift/repositories/sources.py +256 -0
  211. secondshift/repositories/status.py +67 -0
  212. secondshift/runner/README.md +65 -0
  213. secondshift/runner/__init__.py +1 -0
  214. secondshift/runner/agent_credentials.py +89 -0
  215. secondshift/runner/agent_image/Dockerfile +16 -0
  216. secondshift/runner/agent_image/agent.py +132 -0
  217. secondshift/runner/agent_image/claude.py +254 -0
  218. secondshift/runner/agent_image/codex.py +84 -0
  219. secondshift/runner/agent_image/outcomes.py +120 -0
  220. secondshift/runner/agent_image/subagent_hook.py +71 -0
  221. secondshift/runner/agent_protocol.py +41 -0
  222. secondshift/runner/agents.py +254 -0
  223. secondshift/runner/api.py +123 -0
  224. secondshift/runner/apps.py +6 -0
  225. secondshift/runner/client.py +168 -0
  226. secondshift/runner/collection.py +276 -0
  227. secondshift/runner/dao.py +64 -0
  228. secondshift/runner/dev.py +19 -0
  229. secondshift/runner/images.py +57 -0
  230. secondshift/runner/migrations/0001_initial.py +48 -0
  231. secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
  232. secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
  233. secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
  234. secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
  235. secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
  236. secondshift/runner/migrations/__init__.py +0 -0
  237. secondshift/runner/models.py +53 -0
  238. secondshift/runner/protocol.py +97 -0
  239. secondshift/runner/schemas.py +36 -0
  240. secondshift/runner/services.py +465 -0
  241. secondshift/runner/sources.py +130 -0
  242. secondshift/runner/urls.py +7 -0
  243. secondshift/runs/__init__.py +0 -0
  244. secondshift/runs/access.py +13 -0
  245. secondshift/runs/admin.py +89 -0
  246. secondshift/runs/api.py +211 -0
  247. secondshift/runs/apps.py +11 -0
  248. secondshift/runs/artifacts.py +261 -0
  249. secondshift/runs/dao.py +640 -0
  250. secondshift/runs/deliveries.py +80 -0
  251. secondshift/runs/execution/__init__.py +0 -0
  252. secondshift/runs/execution/access_needs.py +89 -0
  253. secondshift/runs/execution/agent.py +91 -0
  254. secondshift/runs/execution/agent_broker.py +198 -0
  255. secondshift/runs/execution/budgets.py +177 -0
  256. secondshift/runs/execution/cli_agent.py +167 -0
  257. secondshift/runs/execution/consent.py +58 -0
  258. secondshift/runs/execution/deadlines.py +47 -0
  259. secondshift/runs/execution/external_tools.py +84 -0
  260. secondshift/runs/execution/issues.py +99 -0
  261. secondshift/runs/execution/model_gateway.py +90 -0
  262. secondshift/runs/execution/payload.py +63 -0
  263. secondshift/runs/execution/result.py +59 -0
  264. secondshift/runs/execution/run.py +345 -0
  265. secondshift/runs/execution/subagents.py +246 -0
  266. secondshift/runs/execution/tool_broker.py +123 -0
  267. secondshift/runs/execution/workspace_tools.py +248 -0
  268. secondshift/runs/history.py +182 -0
  269. secondshift/runs/inputs.py +49 -0
  270. secondshift/runs/issue_delivery.py +144 -0
  271. secondshift/runs/management/__init__.py +0 -0
  272. secondshift/runs/management/commands/__init__.py +0 -0
  273. secondshift/runs/management/commands/prune_runs.py +12 -0
  274. secondshift/runs/management/commands/run_dbos_worker.py +129 -0
  275. secondshift/runs/migration_helpers.py +313 -0
  276. secondshift/runs/migrations/0001_initial.py +69 -0
  277. secondshift/runs/migrations/0002_run_started_by.py +18 -0
  278. secondshift/runs/migrations/0003_agentlease.py +25 -0
  279. secondshift/runs/migrations/0004_task_assignment.py +39 -0
  280. secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
  281. secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
  282. secondshift/runs/migrations/0007_run_delivery.py +35 -0
  283. secondshift/runs/migrations/0008_run_outputs.py +18 -0
  284. secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
  285. secondshift/runs/migrations/0010_run_issue.py +32 -0
  286. secondshift/runs/migrations/0011_issues_delivery.py +23 -0
  287. secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
  288. secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
  289. secondshift/runs/migrations/0014_run_access_need.py +30 -0
  290. secondshift/runs/migrations/__init__.py +0 -0
  291. secondshift/runs/models.py +340 -0
  292. secondshift/runs/orchestration/__init__.py +0 -0
  293. secondshift/runs/orchestration/admission.py +15 -0
  294. secondshift/runs/orchestration/dbos_runtime.py +43 -0
  295. secondshift/runs/orchestration/dispatch.py +130 -0
  296. secondshift/runs/orchestration/periodic.py +39 -0
  297. secondshift/runs/orchestration/reconciliation.py +98 -0
  298. secondshift/runs/orchestration/schedules.py +51 -0
  299. secondshift/runs/orchestration/workflows.py +480 -0
  300. secondshift/runs/outputs.py +52 -0
  301. secondshift/runs/publishing.py +162 -0
  302. secondshift/runs/queue.py +26 -0
  303. secondshift/runs/reporting/__init__.py +0 -0
  304. secondshift/runs/reporting/collection.py +26 -0
  305. secondshift/runs/reporting/connection_usage.py +45 -0
  306. secondshift/runs/reporting/failures.py +150 -0
  307. secondshift/runs/reporting/health.py +158 -0
  308. secondshift/runs/reporting/notifications.py +258 -0
  309. secondshift/runs/reporting/queries.py +60 -0
  310. secondshift/runs/retention.py +35 -0
  311. secondshift/runs/schemas.py +192 -0
  312. secondshift/runs/serialization.py +245 -0
  313. secondshift/runs/services.py +176 -0
  314. secondshift/runs/signals.py +25 -0
  315. secondshift/runs/submissions.py +236 -0
  316. secondshift/runs/transitions.py +84 -0
  317. secondshift/templates/account/password_reset_help_text.html +1 -0
  318. secondshift/templates/admin/base_site.html +24 -0
  319. secondshift/templates/allauth/elements/provider.html +9 -0
  320. secondshift/templates/allauth/layouts/base.html +67 -0
  321. secondshift/templates/socialaccount/snippets/login.html +9 -0
  322. secondshift/templates/workspaces/invite_unusable.html +10 -0
  323. secondshift/triggers/__init__.py +0 -0
  324. secondshift/triggers/admin.py +19 -0
  325. secondshift/triggers/api.py +116 -0
  326. secondshift/triggers/apps.py +6 -0
  327. secondshift/triggers/dao.py +66 -0
  328. secondshift/triggers/filters.py +48 -0
  329. secondshift/triggers/migrations/0001_initial.py +55 -0
  330. secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
  331. secondshift/triggers/migrations/__init__.py +0 -0
  332. secondshift/triggers/models.py +83 -0
  333. secondshift/triggers/schemas.py +58 -0
  334. secondshift/triggers/serialization.py +91 -0
  335. secondshift/triggers/services.py +246 -0
  336. secondshift/triggers/verification.py +84 -0
  337. secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
  338. secondshift/web/assets/index-D27gB9_L.css +1 -0
  339. secondshift/web/favicon.svg +19 -0
  340. secondshift/web/index.html +16 -0
  341. secondshift/web/secondshift-mark.svg +16 -0
  342. secondshift/workspaces/__init__.py +0 -0
  343. secondshift/workspaces/access.py +181 -0
  344. secondshift/workspaces/admin.py +37 -0
  345. secondshift/workspaces/api.py +264 -0
  346. secondshift/workspaces/apps.py +6 -0
  347. secondshift/workspaces/context_processors.py +11 -0
  348. secondshift/workspaces/dao.py +281 -0
  349. secondshift/workspaces/invites.py +150 -0
  350. secondshift/workspaces/limits.py +197 -0
  351. secondshift/workspaces/management/__init__.py +0 -0
  352. secondshift/workspaces/management/commands/__init__.py +0 -0
  353. secondshift/workspaces/management/commands/create_workspace.py +39 -0
  354. secondshift/workspaces/migrations/0001_initial.py +67 -0
  355. secondshift/workspaces/migrations/0002_api_token.py +34 -0
  356. secondshift/workspaces/migrations/0003_execution_default.py +29 -0
  357. secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
  358. secondshift/workspaces/migrations/__init__.py +0 -0
  359. secondshift/workspaces/models.py +169 -0
  360. secondshift/workspaces/owners.py +58 -0
  361. secondshift/workspaces/schemas.py +149 -0
  362. secondshift/workspaces/serialization.py +138 -0
  363. secondshift/workspaces/server_settings.py +140 -0
  364. secondshift/workspaces/services.py +94 -0
  365. secondshift/workspaces/tokens.py +108 -0
  366. secondshift/workspaces/views.py +32 -0
  367. secondshift-0.1.0b12.dist-info/METADATA +270 -0
  368. secondshift-0.1.0b12.dist-info/RECORD +371 -0
  369. secondshift-0.1.0b12.dist-info/WHEEL +4 -0
  370. secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
  371. secondshift-0.1.0b12.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,352 @@
1
+ """OAuth sign-in for MCP servers: discovery, registration, PKCE, and refresh.
2
+
3
+ Follows the MCP authorization spec. Every URL a server advertises passes the
4
+ outbound check before SecondShift calls it. No token, verifier, or remote
5
+ error text leaves this module.
6
+ """
7
+
8
+ import base64
9
+ import hashlib
10
+ import json
11
+ import re
12
+ import secrets
13
+ from dataclasses import dataclass
14
+ from datetime import timedelta
15
+ from typing import Any
16
+ from urllib.parse import urlsplit
17
+
18
+ import anyio
19
+ import httpx
20
+ from asgiref.sync import async_to_sync
21
+ from django.conf import settings
22
+ from django.db import transaction
23
+ from django.utils import timezone
24
+
25
+ from secondshift.connections.dao import get_connection, lock_connection, mark_expired
26
+ from secondshift.connections.mcp.base import MCPError, MCPProvider, MCPRequestError
27
+ from secondshift.connections.mcp.providers import mcp_provider
28
+ from secondshift.connections.models import Connection
29
+ from secondshift.connections.outbound import (
30
+ OutboundUrlError,
31
+ outbound_client,
32
+ validate_outbound_url,
33
+ )
34
+
35
+ OAUTH = "oauth"
36
+ CALLBACK_PATH = "/api/connections/oauth/callback"
37
+ AUTHORIZATION_TTL = timedelta(minutes=10)
38
+ # Refresh a little early so a token never expires mid-call.
39
+ REFRESH_MARGIN = timedelta(seconds=60)
40
+ DEFAULT_TOKEN_LIFETIME = timedelta(hours=1)
41
+ MAX_SCOPES = 20
42
+ SCOPE_PATTERN = r"[\x21\x23-\x5b\x5d-\x7e]{1,100}"
43
+ # Codes that mean the grant is gone, not that the server is briefly down.
44
+ GRANT_ERRORS = {"invalid_grant", "invalid_client", "unauthorized_client"}
45
+ CLIENT_NAME = "SecondShift"
46
+
47
+
48
+ class OAuthGrantError(MCPError):
49
+ """The server refused the grant. The owner must sign in again."""
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class OAuthServer:
54
+ resource: str
55
+ issuer: str
56
+ authorization_endpoint: str
57
+ token_endpoint: str
58
+ registration_endpoint: str
59
+ scopes_supported: tuple[str, ...]
60
+
61
+
62
+ def redirect_uri() -> str:
63
+ return settings.FRONTEND_URL.rstrip("/") + CALLBACK_PATH
64
+
65
+
66
+ def checked_url(value: Any) -> str:
67
+ """A URL the server advertised. Same rules as any other outbound call."""
68
+ if not isinstance(value, str) or not value:
69
+ raise MCPError("The server's sign-in settings are incomplete.")
70
+ try:
71
+ return validate_outbound_url(value, "sign-in URL", resolve=False)
72
+ except OutboundUrlError:
73
+ raise MCPError(
74
+ "The server's sign-in settings use an address SecondShift cannot call."
75
+ ) from None
76
+
77
+
78
+ def origin(url: str) -> str:
79
+ parts = urlsplit(url)
80
+ return f"{parts.scheme}://{parts.netloc}"
81
+
82
+
83
+ def pkce_pair() -> tuple[str, str]:
84
+ verifier = secrets.token_urlsafe(64)
85
+ digest = hashlib.sha256(verifier.encode()).digest()
86
+ challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
87
+ return verifier, challenge
88
+
89
+
90
+ async def request_json(
91
+ method: str,
92
+ url: str,
93
+ *,
94
+ data: dict[str, str] | None = None,
95
+ payload: dict[str, Any] | None = None,
96
+ ) -> tuple[int, dict[str, Any] | None, httpx.Headers]:
97
+ """Status, parsed JSON body (or None), and headers. Never raises remote text."""
98
+ try:
99
+ with anyio.fail_after(settings.OUTBOUND_TIMEOUT_SECONDS):
100
+ async with outbound_client({"Accept": "application/json"}) as client:
101
+ response = await client.request(method, url, data=data, json=payload)
102
+ body = await response.aread()
103
+ except Exception:
104
+ raise MCPRequestError("The sign-in server could not be reached.") from None
105
+ try:
106
+ parsed = json.loads(body) if body else None
107
+ except ValueError:
108
+ parsed = None
109
+ return (
110
+ response.status_code,
111
+ parsed if isinstance(parsed, dict) else None,
112
+ response.headers,
113
+ )
114
+
115
+
116
+ async def first_json(urls: list[str]) -> dict[str, Any] | None:
117
+ for url in dict.fromkeys(urls):
118
+ status, body, _ = await request_json("GET", url)
119
+ if status == 200 and body:
120
+ return body
121
+ return None
122
+
123
+
124
+ def resource_metadata_hint(headers: httpx.Headers) -> str | None:
125
+ challenge = headers.get("www-authenticate", "")
126
+ match = re.search(r'resource_metadata="([^"]+)"', challenge)
127
+ return match.group(1) if match else None
128
+
129
+
130
+ async def discover(resource_url: str) -> OAuthServer | None:
131
+ """The server's OAuth settings, or None when it does not offer sign-in."""
132
+ probe = {
133
+ "jsonrpc": "2.0",
134
+ "id": 1,
135
+ "method": "initialize",
136
+ "params": {
137
+ "protocolVersion": "2025-06-18",
138
+ "capabilities": {},
139
+ "clientInfo": {"name": CLIENT_NAME, "version": "1"},
140
+ },
141
+ }
142
+ status, _, headers = await request_json("POST", resource_url, payload=probe)
143
+ candidates = []
144
+ if status == 401 and (hint := resource_metadata_hint(headers)):
145
+ candidates.append(checked_url(hint))
146
+ path = urlsplit(resource_url).path.rstrip("/")
147
+ candidates += [
148
+ f"{origin(resource_url)}/.well-known/oauth-protected-resource{path}",
149
+ f"{origin(resource_url)}/.well-known/oauth-protected-resource",
150
+ ]
151
+ resource = await first_json(candidates)
152
+ if not resource:
153
+ return None
154
+ servers = resource.get("authorization_servers") or []
155
+ canonical = str(resource.get("resource") or resource_url).rstrip("/")
156
+ if not servers or not resource_url.rstrip("/").startswith(canonical):
157
+ raise MCPError("The server's sign-in settings do not match its address.")
158
+ issuer = checked_url(servers[0]).rstrip("/")
159
+ issuer_path = urlsplit(issuer).path.rstrip("/")
160
+ metadata = await first_json(
161
+ [
162
+ f"{origin(issuer)}/.well-known/oauth-authorization-server{issuer_path}",
163
+ f"{origin(issuer)}/.well-known/openid-configuration{issuer_path}",
164
+ f"{issuer}/.well-known/openid-configuration",
165
+ f"{origin(issuer)}/.well-known/oauth-authorization-server",
166
+ ]
167
+ )
168
+ if not metadata or str(metadata.get("issuer", "")).rstrip("/") != issuer:
169
+ raise MCPError("The server's sign-in settings could not be verified.")
170
+ if "S256" not in (metadata.get("code_challenge_methods_supported") or []):
171
+ raise MCPError("The server does not support secure sign-in (PKCE).")
172
+ if not metadata.get("registration_endpoint"):
173
+ raise MCPError("The server does not let new apps register for sign-in.")
174
+ scopes = resource.get("scopes_supported") or metadata.get("scopes_supported") or []
175
+ return OAuthServer(
176
+ resource=checked_url(canonical),
177
+ issuer=issuer,
178
+ authorization_endpoint=checked_url(metadata.get("authorization_endpoint")),
179
+ token_endpoint=checked_url(metadata.get("token_endpoint")),
180
+ registration_endpoint=checked_url(metadata.get("registration_endpoint")),
181
+ scopes_supported=tuple(
182
+ str(scope)
183
+ for scope in scopes[:MAX_SCOPES]
184
+ if re.fullmatch(SCOPE_PATTERN, str(scope))
185
+ ),
186
+ )
187
+
188
+
189
+ async def register_client(server: OAuthServer) -> str:
190
+ """A public client: PKCE instead of a client secret."""
191
+ status, body, _ = await request_json(
192
+ "POST",
193
+ server.registration_endpoint,
194
+ payload={
195
+ "client_name": CLIENT_NAME,
196
+ "redirect_uris": [redirect_uri()],
197
+ "grant_types": ["authorization_code", "refresh_token"],
198
+ "response_types": ["code"],
199
+ "token_endpoint_auth_method": "none",
200
+ },
201
+ )
202
+ if status not in {200, 201} or not body or not body.get("client_id"):
203
+ raise MCPRequestError("The server did not register SecondShift for sign-in.")
204
+ if (
205
+ body.get("client_secret")
206
+ or body.get("token_endpoint_auth_method", "none") != "none"
207
+ ):
208
+ raise MCPError(
209
+ "The server requires a client secret, which is not supported yet."
210
+ )
211
+ return str(body["client_id"])
212
+
213
+
214
+ async def token_request(endpoint: str, form: dict[str, str]) -> dict[str, Any]:
215
+ status, body, _ = await request_json("POST", endpoint, data=form)
216
+ if status == 200 and body and body.get("access_token"):
217
+ if str(body.get("token_type", "bearer")).lower() != "bearer":
218
+ raise MCPError("The server issued an unsupported token type.")
219
+ return body
220
+ if status in {400, 401} and body and body.get("error") in GRANT_ERRORS:
221
+ raise OAuthGrantError("The server no longer accepts this sign-in.")
222
+ raise MCPRequestError("The sign-in server could not issue a token.")
223
+
224
+
225
+ def granted_scopes(tokens: dict[str, Any], requested: list[str]) -> list[str]:
226
+ scope = tokens.get("scope")
227
+ if scope is None:
228
+ return sorted(set(requested))
229
+ if not isinstance(scope, str):
230
+ raise MCPError("The server returned invalid OAuth scopes.")
231
+ granted = sorted(set(scope.split()))
232
+ if len(granted) > MAX_SCOPES or any(
233
+ not re.fullmatch(SCOPE_PATTERN, item) for item in granted
234
+ ):
235
+ raise MCPError("The server returned invalid OAuth scopes.")
236
+ return granted
237
+
238
+
239
+ def requested_scopes(connection: Connection) -> list[str]:
240
+ """What the connection asked for, apart from what the server granted."""
241
+ return list(connection.metadata.get("oauth", {}).get("requested_scopes", []))
242
+
243
+
244
+ def apply_tokens(connection: Connection, tokens: dict[str, Any]) -> None:
245
+ connection.access_token = str(tokens["access_token"])
246
+ if tokens.get("refresh_token"):
247
+ connection.refresh_token = str(tokens["refresh_token"])
248
+ try:
249
+ lifetime = timedelta(seconds=int(tokens.get("expires_in", 0)))
250
+ except (TypeError, ValueError):
251
+ lifetime = timedelta()
252
+ connection.expires_at = timezone.now() + (lifetime or DEFAULT_TOKEN_LIFETIME)
253
+
254
+
255
+ def inspect_server(provider_key: str, values: dict[str, str]) -> OAuthServer | None:
256
+ provider = mcp_provider(provider_key)
257
+ if OAUTH not in provider.auth_methods:
258
+ return None
259
+ resource_url = provider.endpoint(provider.clean_settings(values))
260
+ return async_to_sync(discover)(resource_url)
261
+
262
+
263
+ def expire(connection: Connection) -> None:
264
+ mark_expired(connection.pk)
265
+ connection.status = Connection.Status.EXPIRED
266
+
267
+
268
+ def fresh_connection(connection: Connection) -> Connection:
269
+ """Refresh an OAuth token that expires soon. Updates `connection` in place.
270
+
271
+ The row is locked so parallel tool calls refresh once. A refresh token can
272
+ be single use, so a refused refresh is checked against the saved row first.
273
+ A refused grant marks the connection expired after the lock is released,
274
+ so the status change is not rolled back with the error.
275
+ """
276
+ if (
277
+ connection.auth_strategy != Connection.AuthStrategy.OAUTH
278
+ or connection.pk is None
279
+ or connection.status != Connection.Status.CONNECTED
280
+ or (
281
+ connection.expires_at
282
+ and connection.expires_at - REFRESH_MARGIN > timezone.now()
283
+ )
284
+ ):
285
+ return connection
286
+ provider: MCPProvider = mcp_provider(connection.provider)
287
+ with transaction.atomic():
288
+ failure = refresh_locked(connection)
289
+ if failure:
290
+ expire(connection)
291
+ raise MCPRequestError(failure.format(label=provider.label))
292
+ return connection
293
+
294
+
295
+ def refresh_locked(connection: Connection) -> str | None:
296
+ """Refresh under a row lock. Returns why the sign-in can no longer be used."""
297
+ expired = "The {label} sign-in expired. Reconnect it."
298
+ locked = lock_connection(connection.pk)
299
+ if locked.expires_at and locked.expires_at - REFRESH_MARGIN > timezone.now():
300
+ copy_tokens(locked, connection)
301
+ return None
302
+ oauth = locked.metadata.get("oauth", {})
303
+ if not locked.refresh_token or not oauth.get("token_endpoint"):
304
+ return expired
305
+ used_refresh_token = locked.refresh_token
306
+ try:
307
+ tokens = async_to_sync(token_request)(
308
+ oauth["token_endpoint"],
309
+ {
310
+ "grant_type": "refresh_token",
311
+ "refresh_token": used_refresh_token,
312
+ "client_id": oauth["client_id"],
313
+ "resource": oauth.get("resource", ""),
314
+ },
315
+ )
316
+ except OAuthGrantError:
317
+ latest = get_connection(connection.pk)
318
+ if latest.refresh_token != used_refresh_token:
319
+ copy_tokens(latest, connection)
320
+ return None
321
+ return expired
322
+ required = requested_scopes(locked)
323
+ granted = granted_scopes(tokens, locked.scopes)
324
+ if set(required) - set(granted):
325
+ return "{label} did not grant the required access. Reconnect it."
326
+ locked.scopes = granted
327
+ locked.metadata = {
328
+ **locked.metadata,
329
+ "oauth": {**oauth, "requested_scopes": required},
330
+ }
331
+ apply_tokens(locked, tokens)
332
+ locked.save(
333
+ update_fields=[
334
+ "access_token",
335
+ "refresh_token",
336
+ "expires_at",
337
+ "scopes",
338
+ "metadata",
339
+ "updated_at",
340
+ ]
341
+ )
342
+ copy_tokens(locked, connection)
343
+ return None
344
+
345
+
346
+ def copy_tokens(source: Connection, target: Connection) -> None:
347
+ target.access_token = source.access_token
348
+ target.refresh_token = source.refresh_token
349
+ target.expires_at = source.expires_at
350
+ target.status = source.status
351
+ target.scopes = list(source.scopes)
352
+ target.metadata = source.metadata
@@ -0,0 +1,15 @@
1
+ from secondshift.connections.mcp.base import MCPError, MCPProvider
2
+ from secondshift.connections.mcp.custom import CUSTOM_MCP
3
+ from secondshift.connections.mcp.github import GITHUB_MCP
4
+ from secondshift.connections.mcp.sentry import SENTRY_MCP
5
+
6
+ MCP_PROVIDERS: dict[str, MCPProvider] = {
7
+ provider.key: provider for provider in (GITHUB_MCP, SENTRY_MCP, CUSTOM_MCP)
8
+ }
9
+
10
+
11
+ def mcp_provider(key: str) -> MCPProvider:
12
+ try:
13
+ return MCP_PROVIDERS[key]
14
+ except KeyError:
15
+ raise MCPError("This MCP provider is not supported.") from None
@@ -0,0 +1,77 @@
1
+ """Bounded, document-local JSON Schema references for MCP tool arguments."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+ from urllib.parse import unquote
7
+
8
+ from secondshift.connections.mcp.base import MCPError
9
+
10
+ MCP_SCHEMA_MAX_DEPTH = 30
11
+ MCP_SCHEMA_MAX_NODES = 10_000
12
+
13
+
14
+ def reference_target(root: Any, reference: Any) -> dict[str, Any] | bool:
15
+ """Resolve only JSON Pointers in this document, without a network resolver."""
16
+ if not isinstance(reference, str) or not reference.startswith("#/"):
17
+ raise MCPError("Only local JSON Pointer MCP schema references are supported.")
18
+ try:
19
+ pointer = unquote(reference[2:], errors="strict")
20
+ target = root
21
+ for token in pointer.split("/"):
22
+ if re.search(r"~(?![01])", token):
23
+ raise ValueError
24
+ token = token.replace("~1", "/").replace("~0", "~")
25
+ if isinstance(target, list) and re.fullmatch(r"0|[1-9][0-9]*", token):
26
+ target = target[int(token)]
27
+ elif isinstance(target, dict):
28
+ target = target[token]
29
+ else:
30
+ raise ValueError
31
+ except (KeyError, IndexError, ValueError):
32
+ raise MCPError("The MCP schema contains an invalid local reference.") from None
33
+ if not isinstance(target, dict | bool):
34
+ raise MCPError("The MCP schema reference must point to a schema.")
35
+ return target
36
+
37
+
38
+ @dataclass
39
+ class ToolSchemaValidator:
40
+ root: Any
41
+ remaining: int = MCP_SCHEMA_MAX_NODES
42
+
43
+ def visit(self, value: Any, depth: int, ancestors: frozenset[int]) -> None:
44
+ self.remaining -= 1
45
+ if self.remaining < 0:
46
+ raise MCPError("The MCP schema is too complex.")
47
+ if depth > MCP_SCHEMA_MAX_DEPTH:
48
+ raise MCPError("The MCP schema is too deeply nested.")
49
+ if not isinstance(value, dict | list):
50
+ return
51
+ if id(value) in ancestors:
52
+ raise MCPError("The MCP schema contains recursive references.")
53
+ ancestors = ancestors | {id(value)}
54
+ if isinstance(value, dict):
55
+ self.check_references(value, depth, ancestors)
56
+ children = value.values()
57
+ else:
58
+ children = value
59
+ for child in children:
60
+ self.visit(child, depth + 1, ancestors)
61
+
62
+ def check_references(
63
+ self, value: dict[str, Any], depth: int, ancestors: frozenset[int]
64
+ ) -> None:
65
+ if "$dynamicRef" in value or "$recursiveRef" in value:
66
+ raise MCPError("Dynamic MCP schema references are not supported.")
67
+ # Embedded resources change what a local pointer means downstream.
68
+ # Keep all references relative to the single tool schema document.
69
+ if depth and "$id" in value:
70
+ raise MCPError("Nested MCP schema identifiers are not supported.")
71
+ if "$ref" in value:
72
+ self.visit(reference_target(self.root, value["$ref"]), depth + 1, ancestors)
73
+
74
+
75
+ def validate_tool_schema(value: Any, depth: int = 0) -> None:
76
+ """Validate the reference graph while retaining schemas and sibling constraints."""
77
+ ToolSchemaValidator(value).visit(value, depth, frozenset())
@@ -0,0 +1,135 @@
1
+ """Sentry's hosted MCP server, limited to one organization or project."""
2
+
3
+ from typing import Any
4
+
5
+ from secondshift.config.constants import (
6
+ SENTRY_MCP_TOOLS,
7
+ SENTRY_MCP_URL,
8
+ SENTRY_SLUG_PATTERN,
9
+ SentryMCPTool,
10
+ )
11
+ from secondshift.connections.access import AccessReason
12
+ from secondshift.connections.mcp.base import (
13
+ MCPArgumentError,
14
+ MCPError,
15
+ MCPProvider,
16
+ MCPSetting,
17
+ MCPSettings,
18
+ )
19
+ from secondshift.connections.models import Connection
20
+ from secondshift.connections.repository_ref import RepositoryRef
21
+
22
+ ORGANIZATION = "organization"
23
+ PROJECT = "project"
24
+ # Sentry's hosted server reads a pasted token from this scheme. Plain Bearer is
25
+ # reserved for its own OAuth tokens.
26
+ SENTRY_AUTH_SCHEME = "Sentry-Bearer"
27
+ # Search text is written by the model and read by Sentry's search agent.
28
+ SENTRY_QUERY_MAX_CHARS = 256
29
+ # Replays and agent conversations are left out. They often hold personal data.
30
+ SENTRY_RESOURCE_TYPES = {"issue", "event", "trace"}
31
+
32
+
33
+ def sentry_scope(settings: MCPSettings, repository: RepositoryRef) -> str | None:
34
+ """The owner chose the reach when connecting. Every job shares it."""
35
+ del repository
36
+ organization = settings.get(ORGANIZATION)
37
+ if not organization:
38
+ return None
39
+ project = settings.get(PROJECT)
40
+ return f"{organization}/{project}" if project else organization
41
+
42
+
43
+ def sentry_endpoint_path(settings: MCPSettings) -> str:
44
+ # Sentry limits its tools to this organization or project.
45
+ return "".join(
46
+ f"/{settings[key]}" for key in (ORGANIZATION, PROJECT) if key in settings
47
+ )
48
+
49
+
50
+ def describe_sentry_scope(scope: str) -> str:
51
+ organization, _, project = scope.partition("/")
52
+ if project:
53
+ return f"the {organization}/{project} Sentry project"
54
+ return f"all Sentry projects in {organization}"
55
+
56
+
57
+ def scoped_sentry_arguments(
58
+ scope: str, tool: str, arguments: dict[str, Any]
59
+ ) -> dict[str, Any]:
60
+ """Keep model arguments inside the approved organization or project.
61
+
62
+ The endpoint already limits Sentry to the scope. These checks stop the
63
+ model from naming another place to read.
64
+ """
65
+ if tool not in SENTRY_MCP_TOOLS:
66
+ raise MCPError("This Sentry tool is not supported.")
67
+ # A region URL picks the Sentry host. The scoped endpoint finds it itself.
68
+ if arguments.get("regionUrl"):
69
+ raise MCPArgumentError(
70
+ "Leave regionUrl empty. The approved scope sets the region."
71
+ )
72
+ _, _, project = scope.partition("/")
73
+ for key in ("projectSlug", "projectSlugOrId"):
74
+ value = arguments.get(key)
75
+ if project and value and value != project:
76
+ raise MCPArgumentError("The tool must target the approved Sentry project.")
77
+ query = arguments.get("query")
78
+ if isinstance(query, str) and len(query) > SENTRY_QUERY_MAX_CHARS:
79
+ raise MCPArgumentError(
80
+ f"Keep the search under {SENTRY_QUERY_MAX_CHARS} characters."
81
+ )
82
+ if tool == SentryMCPTool.GET_SENTRY_RESOURCE:
83
+ # A URL could name any organization. Look resources up by ID instead.
84
+ if arguments.get("url"):
85
+ raise MCPArgumentError(
86
+ "Fetch Sentry resources by resourceType and resourceId."
87
+ )
88
+ if arguments.get("resourceType") not in SENTRY_RESOURCE_TYPES:
89
+ raise MCPArgumentError("Only issues, events, and traces can be fetched.")
90
+ if not arguments.get("resourceId"):
91
+ raise MCPArgumentError(
92
+ "Fetch Sentry resources by resourceType and resourceId."
93
+ )
94
+ return dict(arguments)
95
+
96
+
97
+ SENTRY_MCP = MCPProvider(
98
+ key=Connection.Provider.SENTRY,
99
+ label="Sentry",
100
+ endpoint_url=SENTRY_MCP_URL,
101
+ tools=SENTRY_MCP_TOOLS,
102
+ scope=sentry_scope,
103
+ scope_arguments=scoped_sentry_arguments,
104
+ describe_scope=describe_sentry_scope,
105
+ scope_label="Sentry scope",
106
+ missing_scope_reason=AccessReason.SENTRY_ORGANIZATION_UNKNOWN,
107
+ missing_scope_message="This Sentry connection has no organization.",
108
+ settings=(
109
+ MCPSetting(
110
+ key=ORGANIZATION,
111
+ label="Organization",
112
+ pattern=SENTRY_SLUG_PATTERN,
113
+ help="The organization slug from your Sentry URL.",
114
+ placeholder="acme",
115
+ ),
116
+ MCPSetting(
117
+ key=PROJECT,
118
+ label="Project",
119
+ pattern=SENTRY_SLUG_PATTERN,
120
+ required=False,
121
+ help="Optional. Leave empty to allow every project in the organization.",
122
+ placeholder="backend",
123
+ ),
124
+ ),
125
+ endpoint_path=sentry_endpoint_path,
126
+ auth_scheme=SENTRY_AUTH_SCHEME,
127
+ require_read_only_hint=True,
128
+ token_pattern=r"[A-Za-z0-9_=.+/-]+",
129
+ token_hint="Enter a Sentry auth token without whitespace.",
130
+ description="Read issues, errors, and traces from one organization or project.",
131
+ default_binding="sentry",
132
+ token_label="Auth token",
133
+ setup_url="https://sentry.io/settings/account/api/auth-tokens/",
134
+ setup_help="Use only org:read, project:read, and event:read. Write scopes are not checked.",
135
+ )