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,382 @@
1
+ """Read an OpenAPI 3 spec into operations a job can call.
2
+
3
+ The spec only describes operations. Where requests go is the base URL the owner
4
+ confirmed, which is saved with the connection and never read from the spec at
5
+ call time.
6
+ """
7
+
8
+ import json
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+ from urllib.parse import urljoin, urlsplit
13
+
14
+ import anyio
15
+ import yaml
16
+ from asgiref.sync import async_to_sync
17
+ from django.conf import settings
18
+ from jsonschema import Draft202012Validator
19
+ from jsonschema.exceptions import SchemaError
20
+
21
+ from secondshift.connections.errors import (
22
+ ExternalRequestError,
23
+ ExternalToolError,
24
+ ToolArgumentError,
25
+ )
26
+ from secondshift.connections.outbound import (
27
+ OutboundUrlError,
28
+ outbound_client,
29
+ validate_outbound_url,
30
+ )
31
+
32
+ SPEC_MAX_BYTES = 5 * 1024 * 1024
33
+ MAX_OPERATIONS = 500
34
+ SCHEMA_MAX_DEPTH = 20
35
+ SCHEMA_MAX_BYTES = 64 * 1024
36
+ DESCRIPTION_MAX_CHARS = 500
37
+ METHODS = ("get", "post", "put", "patch", "delete")
38
+ OPERATION_NAME_PATTERN = r"[A-Za-z0-9_.-]{1,64}"
39
+ # Path and query only. Headers and cookies stay under SecondShift's control.
40
+ PARAMETER_LOCATIONS = {"path", "query"}
41
+ BODY = "body"
42
+
43
+
44
+ class APIError(ExternalToolError):
45
+ pass
46
+
47
+
48
+ class APIRequestError(APIError, ExternalRequestError):
49
+ """A remote failure the agent may report as unavailable evidence."""
50
+
51
+
52
+ class APIArgumentError(APIError, ToolArgumentError):
53
+ """Arguments the operation cannot take. The model may fix them."""
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Parameter:
58
+ name: str
59
+ location: str
60
+ required: bool
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Operation:
65
+ name: str
66
+ method: str
67
+ path: str
68
+ description: str
69
+ parameters: tuple[Parameter, ...]
70
+ has_body: bool
71
+ input_schema: dict[str, Any]
72
+
73
+ @property
74
+ def read_only(self) -> bool:
75
+ return self.method == "get"
76
+
77
+ def definition(self) -> dict[str, Any]:
78
+ """What is stored and frozen into run plans. No credentials."""
79
+ return {
80
+ "method": self.method,
81
+ "path": self.path,
82
+ "description": self.description,
83
+ "parameters": [
84
+ {"name": item.name, "in": item.location, "required": item.required}
85
+ for item in self.parameters
86
+ ],
87
+ "body": self.has_body,
88
+ "inputSchema": self.input_schema,
89
+ "readOnly": self.read_only,
90
+ }
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class Spec:
95
+ title: str
96
+ servers: tuple[str, ...]
97
+ operations: dict[str, Operation] = field(default_factory=dict)
98
+
99
+
100
+ class NoAliasLoader(yaml.SafeLoader):
101
+ """YAML without anchors and aliases, so a small file cannot expand to a huge one."""
102
+
103
+ def compose_node(self, parent: Any, index: Any) -> Any:
104
+ if self.check_event(yaml.AliasEvent):
105
+ raise APIError("Specs with YAML aliases are not supported.")
106
+ return super().compose_node(parent, index)
107
+
108
+
109
+ def parse_document(text: str) -> dict[str, Any]:
110
+ if len(text.encode()) > SPEC_MAX_BYTES:
111
+ raise APIError("The spec is larger than 5 MB.")
112
+ try:
113
+ document = json.loads(text)
114
+ except ValueError:
115
+ try:
116
+ document = yaml.load(text, Loader=NoAliasLoader)
117
+ except yaml.YAMLError:
118
+ raise APIError("The spec is not valid JSON or YAML.") from None
119
+ if not isinstance(document, dict):
120
+ raise APIError("The spec is not an OpenAPI document.")
121
+ if not str(document.get("openapi", "")).startswith("3."):
122
+ raise APIError("Only OpenAPI 3 specs are supported.")
123
+ return document
124
+
125
+
126
+ async def fetch_text(url: str) -> str:
127
+ try:
128
+ with anyio.fail_after(settings.OUTBOUND_TIMEOUT_SECONDS):
129
+ async with outbound_client(
130
+ {"Accept": "application/json, application/yaml, text/yaml, */*"}
131
+ ) as client:
132
+ response = await client.get(url)
133
+ body = await response.aread()
134
+ except Exception:
135
+ raise APIRequestError("The spec could not be downloaded.") from None
136
+ if 300 <= response.status_code < 400:
137
+ raise APIError("The spec URL redirects. Use the final address.")
138
+ if response.status_code != 200:
139
+ raise APIError("The spec could not be downloaded.")
140
+ return body.decode("utf-8", errors="replace")
141
+
142
+
143
+ def checked_url(value: str, noun: str) -> str:
144
+ try:
145
+ return validate_outbound_url(value, noun, resolve=False)
146
+ except OutboundUrlError as error:
147
+ raise APIError(str(error)) from None
148
+
149
+
150
+ def load_spec(spec_url: str = "", spec_text: str = "") -> Spec:
151
+ """From a URL, or from pasted text when the spec is not published."""
152
+ if spec_url:
153
+ spec_url = checked_url(spec_url, "spec URL")
154
+ spec_text = async_to_sync(fetch_text)(spec_url)
155
+ if not spec_text.strip():
156
+ raise APIError("Enter a spec URL or paste a spec.")
157
+ return read_spec(parse_document(spec_text), spec_url)
158
+
159
+
160
+ def read_spec(document: dict[str, Any], spec_url: str = "") -> Spec:
161
+ info = document.get("info") if isinstance(document.get("info"), dict) else {}
162
+ operations: dict[str, Operation] = {}
163
+ paths = document.get("paths") if isinstance(document.get("paths"), dict) else {}
164
+ for path, item in paths.items():
165
+ if (
166
+ not isinstance(path, str)
167
+ or not path.startswith("/")
168
+ or not isinstance(item, dict)
169
+ ):
170
+ continue
171
+ shared = item.get("parameters") or []
172
+ for method in METHODS:
173
+ raw = item.get(method)
174
+ if not isinstance(raw, dict):
175
+ continue
176
+ operation = read_operation(document, path, method, raw, shared)
177
+ if operation is None or operation.name in operations:
178
+ continue
179
+ operations[operation.name] = operation
180
+ if len(operations) > MAX_OPERATIONS:
181
+ raise APIError(f"The spec has more than {MAX_OPERATIONS} operations.")
182
+ if not operations:
183
+ raise APIError("The spec lists no operations SecondShift can call.")
184
+ return Spec(
185
+ title=str(info.get("title", ""))[:200],
186
+ servers=servers(document, spec_url),
187
+ operations=operations,
188
+ )
189
+
190
+
191
+ def servers(document: dict[str, Any], spec_url: str) -> tuple[str, ...]:
192
+ """Absolute server URLs. Relative ones resolve against the spec URL.
193
+
194
+ Variables use their defaults. A server that still has a variable, or that
195
+ fails the outbound check, is left out for the owner to type instead.
196
+ """
197
+ found: list[str] = []
198
+ for item in document.get("servers") or []:
199
+ if not isinstance(item, dict) or not isinstance(item.get("url"), str):
200
+ continue
201
+ url = item["url"]
202
+ for name, variable in (item.get("variables") or {}).items():
203
+ if isinstance(variable, dict) and "default" in variable:
204
+ url = url.replace("{" + str(name) + "}", str(variable["default"]))
205
+ if "{" in url:
206
+ continue
207
+ if not urlsplit(url).scheme:
208
+ if not spec_url:
209
+ continue
210
+ url = urljoin(spec_url, url)
211
+ try:
212
+ found.append(checked_url(url.rstrip("/"), "server URL"))
213
+ except APIError:
214
+ continue
215
+ return tuple(dict.fromkeys(found))
216
+
217
+
218
+ def operation_name(raw: dict[str, Any], method: str, path: str) -> str:
219
+ name = str(raw.get("operationId") or "")
220
+ if re.fullmatch(OPERATION_NAME_PATTERN, name):
221
+ return name
222
+ slug = re.sub(r"[^A-Za-z0-9]+", "_", path).strip("_")
223
+ return f"{method}_{slug}"[:64] or method
224
+
225
+
226
+ def read_operation(
227
+ document: dict[str, Any],
228
+ path: str,
229
+ method: str,
230
+ raw: dict[str, Any],
231
+ shared: list[Any],
232
+ ) -> Operation | None:
233
+ """An operation SecondShift can call, or None when it is too unclear."""
234
+ declared = declared_parameters(document, raw, shared)
235
+ inputs = parameter_inputs(document, declared)
236
+ if inputs is None:
237
+ return None
238
+ properties, required, parameters = inputs
239
+ # Every {name} in the path must be a declared path parameter.
240
+ template_names = set(re.findall(r"{([^}/]+)}", path))
241
+ if template_names != {item.name for item in parameters if item.location == "path"}:
242
+ return None
243
+ body = json_body(document, method, raw)
244
+ if body is not None:
245
+ properties[BODY], body_required = body
246
+ if body_required:
247
+ required.append(BODY)
248
+ input_schema = checked_input_schema(properties, required)
249
+ if input_schema is None:
250
+ return None
251
+ description = str(raw.get("summary") or raw.get("description") or "")
252
+ return Operation(
253
+ name=operation_name(raw, method, path),
254
+ method=method,
255
+ path=path,
256
+ description=f"{method.upper()} {path}. {description}"[:DESCRIPTION_MAX_CHARS],
257
+ parameters=tuple(parameters),
258
+ has_body=body is not None,
259
+ input_schema=input_schema,
260
+ )
261
+
262
+
263
+ def declared_parameters(
264
+ document: dict[str, Any], raw: dict[str, Any], shared: list[Any]
265
+ ) -> dict[tuple[str, str], dict[str, Any]]:
266
+ """Parameters by location and name. The operation's override the path's."""
267
+ declared: dict[tuple[str, str], dict[str, Any]] = {}
268
+ for item in [*shared, *(raw.get("parameters") or [])]:
269
+ parameter = resolve(document, item)
270
+ if not isinstance(parameter, dict):
271
+ continue
272
+ location = parameter.get("in")
273
+ name = parameter.get("name")
274
+ if location in PARAMETER_LOCATIONS and isinstance(name, str) and name != BODY:
275
+ declared[(location, name)] = parameter
276
+ return declared
277
+
278
+
279
+ def parameter_inputs(
280
+ document: dict[str, Any], declared: dict[tuple[str, str], dict[str, Any]]
281
+ ) -> tuple[dict[str, Any], list[str], list[Parameter]] | None:
282
+ """Schema properties, required names and parameters. None when ambiguous."""
283
+ properties: dict[str, Any] = {}
284
+ required: list[str] = []
285
+ parameters: list[Parameter] = []
286
+ for (location, name), parameter in declared.items():
287
+ if name in properties:
288
+ # A path and query parameter share a name. Too ambiguous to call.
289
+ return None
290
+ is_required = location == "path" or bool(parameter.get("required"))
291
+ schema = inline(document, parameter.get("schema") or {"type": "string"})
292
+ if parameter.get("description"):
293
+ schema = {**schema, "description": str(parameter["description"])[:300]}
294
+ properties[name] = schema
295
+ parameters.append(Parameter(name, location, is_required))
296
+ if is_required:
297
+ required.append(name)
298
+ return properties, required, parameters
299
+
300
+
301
+ def json_body(
302
+ document: dict[str, Any], method: str, raw: dict[str, Any]
303
+ ) -> tuple[dict[str, Any], bool] | None:
304
+ """The JSON body schema and whether it is required. None without one."""
305
+ body = resolve(document, raw.get("requestBody"))
306
+ if method == "get" or not isinstance(body, dict):
307
+ return None
308
+ content = body.get("content") if isinstance(body.get("content"), dict) else {}
309
+ media = content.get("application/json")
310
+ if not isinstance(media, dict):
311
+ return None
312
+ return inline(document, media.get("schema") or {}), bool(body.get("required"))
313
+
314
+
315
+ def checked_input_schema(
316
+ properties: dict[str, Any], required: list[str]
317
+ ) -> dict[str, Any] | None:
318
+ """The tool's input schema, or None when too large or not valid."""
319
+ input_schema: dict[str, Any] = {
320
+ "type": "object",
321
+ "properties": properties,
322
+ "additionalProperties": False,
323
+ }
324
+ if required:
325
+ input_schema["required"] = sorted(required)
326
+ if len(json.dumps(input_schema).encode()) > SCHEMA_MAX_BYTES:
327
+ return None
328
+ try:
329
+ Draft202012Validator.check_schema(input_schema)
330
+ except SchemaError:
331
+ return None
332
+ return input_schema
333
+
334
+
335
+ def resolve(document: dict[str, Any], value: Any, depth: int = 0) -> Any:
336
+ """Follow a local $ref such as #/components/parameters/Limit."""
337
+ while isinstance(value, dict) and "$ref" in value:
338
+ if depth > SCHEMA_MAX_DEPTH:
339
+ return {}
340
+ reference = value["$ref"]
341
+ if not isinstance(reference, str) or not reference.startswith("#/"):
342
+ return {}
343
+ target: Any = document
344
+ for part in reference[2:].split("/"):
345
+ part = part.replace("~1", "/").replace("~0", "~")
346
+ target = target.get(part) if isinstance(target, dict) else None
347
+ value = target
348
+ depth += 1
349
+ return value
350
+
351
+
352
+ def inline(document: dict[str, Any], schema: Any, depth: int = 0) -> dict[str, Any]:
353
+ """A schema with every local $ref replaced. Deep or cyclic parts become {}."""
354
+ if depth > SCHEMA_MAX_DEPTH:
355
+ return {}
356
+ schema = resolve(document, schema)
357
+ if not isinstance(schema, dict):
358
+ return {}
359
+ result: dict[str, Any] = {}
360
+ for key, value in schema.items():
361
+ if key in {"$ref", "$dynamicRef", "$recursiveRef", "$id", "$schema"}:
362
+ continue
363
+ # OpenAPI 3.0 writes these as booleans. JSON Schema wants numbers.
364
+ if key in {"exclusiveMinimum", "exclusiveMaximum"} and isinstance(value, bool):
365
+ continue
366
+ if isinstance(value, dict):
367
+ result[key] = (
368
+ {
369
+ name: inline(document, item, depth + 1)
370
+ for name, item in value.items()
371
+ }
372
+ if key in {"properties", "patternProperties", "$defs", "definitions"}
373
+ else inline(document, value, depth + 1)
374
+ )
375
+ elif isinstance(value, list):
376
+ result[key] = [
377
+ inline(document, item, depth + 1) if isinstance(item, dict) else item
378
+ for item in value
379
+ ]
380
+ else:
381
+ result[key] = value
382
+ return result
@@ -0,0 +1 @@
1
+ """MCP connections: a shared client plus one module per supported provider."""
@@ -0,0 +1,139 @@
1
+ import re
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from secondshift.connections.access import AccessReason
7
+ from secondshift.connections.errors import (
8
+ ExternalRequestError,
9
+ ExternalToolError,
10
+ ToolArgumentError,
11
+ )
12
+ from secondshift.connections.repository_ref import RepositoryRef
13
+ from secondshift.workspaces.models import Workspace
14
+
15
+ MCPSettings = dict[str, str]
16
+ # The connection borrows each repository's own GitHub source instead of a token.
17
+ SOURCE_AUTH = "source"
18
+
19
+
20
+ class MCPError(ExternalToolError):
21
+ pass
22
+
23
+
24
+ class MCPRequestError(MCPError, ExternalRequestError):
25
+ """A remote failure the agent may report as unavailable evidence."""
26
+
27
+
28
+ class CustomMCPToolError(MCPRequestError):
29
+ """A custom tool failure with a redacted reason for the model."""
30
+
31
+ def __init__(self, message: str, reason: str) -> None:
32
+ super().__init__(message)
33
+ self.reason = reason
34
+
35
+
36
+ class MCPArgumentError(MCPError, ToolArgumentError):
37
+ """Arguments outside the approved scope. The model may fix them."""
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class MCPSetting:
42
+ """A value the owner enters when connecting, such as a Sentry organization."""
43
+
44
+ key: str
45
+ label: str
46
+ # Matched in full. Keep it to simple character classes so browsers agree.
47
+ pattern: str
48
+ required: bool = True
49
+ help: str = ""
50
+ placeholder: str = ""
51
+ max_length: int = 64
52
+ # Extra check after the pattern, such as an outbound URL check.
53
+ validate: Callable[[str], str] | None = None
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class MCPProvider:
58
+ """Everything SecondShift trusts about one built-in MCP server.
59
+
60
+ The endpoint, tools and scoping rules are server-owned. Repository config
61
+ and model output can never change them.
62
+ """
63
+
64
+ key: str
65
+ label: str
66
+ endpoint_url: str
67
+ # Only these tools are discovered, approved, and called.
68
+ tools: tuple[str, ...]
69
+ # What a job may touch, such as "owner/repo". None means it cannot run.
70
+ scope: Callable[[MCPSettings, RepositoryRef], str | None]
71
+ # Forces model arguments into the approved scope, or raises MCPError.
72
+ scope_arguments: Callable[[str, str, dict[str, Any]], dict[str, Any]]
73
+ # Completes "Allow these tools to read ...".
74
+ describe_scope: Callable[[str], str]
75
+ access_verb: str = "read"
76
+ scope_label: str = "scope"
77
+ missing_scope_reason: AccessReason = AccessReason.MCP_SCOPE_UNKNOWN
78
+ missing_scope_message: str = "This job has no scope for this provider."
79
+ settings: tuple[MCPSetting, ...] = ()
80
+ # Appended to endpoint_url, built only from validated settings.
81
+ endpoint_path: Callable[[MCPSettings], str] = lambda settings: ""
82
+ auth_scheme: str = "Bearer"
83
+ # Fixed request headers, sent alongside the token.
84
+ headers: dict[str, str] = field(default_factory=dict)
85
+ # Rewrites a discovered tool's input schema so the model sees only the
86
+ # arguments scope_arguments accepts. Applied before the schema is stored.
87
+ narrow_schema: Callable[[str, dict[str, Any]], dict[str, Any]] = (
88
+ lambda tool, schema: schema
89
+ )
90
+ # A safe message for a failed tool call, chosen by matching the remote
91
+ # error text, or None to use the client's fallback.
92
+ tool_error: Callable[[str, str], str | None] = lambda tool, text: None
93
+ # Drop tools the server does not mark read-only.
94
+ require_read_only_hint: bool = False
95
+ # Custom servers: keep every tool and let the owner choose. Read-only
96
+ # tools start selected.
97
+ owner_selects_tools: bool = False
98
+ # "token" for a pasted bearer token, "oauth" for a browser sign-in,
99
+ # SOURCE_AUTH for the token of the repository's source connection.
100
+ auth_methods: tuple[str, ...] = ("token",)
101
+ # SOURCE_AUTH only. A token for one repository, minted when a tool is called.
102
+ source_token: Callable[[RepositoryRef], str] | None = None
103
+ # SOURCE_AUTH only. A token for listing tools when the connection is saved.
104
+ discovery_token: Callable[[Workspace], str] | None = None
105
+ # SOURCE_AUTH only. Whether a repository has a source this can borrow.
106
+ source_reachable: Callable[[RepositoryRef], bool] = lambda repository: False
107
+ missing_source_message: str = "This repository has no source connection."
108
+ token_pattern: str = r"\S+"
109
+ token_hint: str = "Enter a token without whitespace."
110
+ # Shown when an owner sets up a connection.
111
+ description: str = ""
112
+ default_binding: str = ""
113
+ token_label: str = "Access token"
114
+ setup_url: str = ""
115
+ setup_help: str = ""
116
+
117
+ def endpoint(self, settings: MCPSettings) -> str:
118
+ return self.endpoint_url + self.endpoint_path(settings)
119
+
120
+ def clean_settings(self, values: dict[str, str]) -> MCPSettings:
121
+ """Keep only declared settings, each matching its pattern."""
122
+ known = {setting.key for setting in self.settings}
123
+ if set(values) - known:
124
+ raise MCPError(f"{self.label} does not accept these settings.")
125
+ cleaned: MCPSettings = {}
126
+ for setting in self.settings:
127
+ value = values.get(setting.key, "").strip()
128
+ if not value:
129
+ if setting.required:
130
+ raise MCPError(f"Enter the {setting.label.lower()}.")
131
+ continue
132
+ if len(value) > setting.max_length or not re.fullmatch(
133
+ setting.pattern, value
134
+ ):
135
+ raise MCPError(f"The {setting.label.lower()} is not valid.")
136
+ if setting.validate:
137
+ value = setting.validate(value)
138
+ cleaned[setting.key] = value
139
+ return cleaned