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,353 @@
1
+ """The shared MCP client. Provider details come from the provider registry."""
2
+
3
+ import json
4
+ import re
5
+ from datetime import timedelta
6
+ from typing import Any
7
+
8
+ import anyio
9
+ from asgiref.sync import async_to_sync
10
+ from django.conf import settings
11
+ from django.db import IntegrityError, transaction
12
+ from jsonschema import Draft202012Validator
13
+ from mcp import ClientSession
14
+ from mcp.client.streamable_http import streamable_http_client
15
+
16
+ from secondshift.connections.dao import binding_taken
17
+ from secondshift.connections.mcp.base import (
18
+ SOURCE_AUTH,
19
+ CustomMCPToolError,
20
+ MCPError,
21
+ MCPProvider,
22
+ MCPRequestError,
23
+ MCPSettings,
24
+ )
25
+ from secondshift.connections.mcp.oauth import fresh_connection
26
+ from secondshift.connections.mcp.providers import mcp_provider
27
+ from secondshift.connections.mcp.schemas import validate_tool_schema
28
+ from secondshift.connections.models import Connection
29
+ from secondshift.connections.outbound import (
30
+ RESPONSE_MAX_BYTES,
31
+ host_label,
32
+ outbound_client,
33
+ )
34
+ from secondshift.connections.services import ConnectionConflict
35
+ from secondshift.connections.tools import UnknownTools, store_tools
36
+ from secondshift.workspaces.models import Workspace
37
+
38
+ MCP_MAX_PAGES = 10
39
+ MCP_ERROR_REASON_MAX_CHARS = 500
40
+ # Custom servers keep every tool for the owner to choose from, up to this many.
41
+ MCP_MAX_OFFERED_TOOLS = 200
42
+ AUTH_STRATEGIES = {
43
+ "token": Connection.AuthStrategy.API_KEY,
44
+ "oauth": Connection.AuthStrategy.OAUTH,
45
+ SOURCE_AUTH: Connection.AuthStrategy.MANAGED,
46
+ }
47
+
48
+
49
+ def validate_mcp_connection(connection: Connection) -> MCPProvider:
50
+ """Fail closed unless the connection matches a built-in provider exactly."""
51
+ provider = mcp_provider(connection.provider)
52
+ if (
53
+ connection.kind != Connection.Kind.MCP
54
+ or connection.endpoint_url
55
+ != provider.endpoint(provider.clean_settings(connection.settings))
56
+ or connection.auth_strategy
57
+ not in {AUTH_STRATEGIES[method] for method in provider.auth_methods}
58
+ or connection.status != Connection.Status.CONNECTED
59
+ or (not borrows_source(connection) and not connection.access_token)
60
+ or connection.token_expired
61
+ ):
62
+ raise MCPError(f"This {provider.label} MCP connection is unavailable.")
63
+ return provider
64
+
65
+
66
+ def borrows_source(connection: Connection) -> bool:
67
+ """The connection uses each repository's source token, and stores none."""
68
+ return connection.auth_strategy == Connection.AuthStrategy.MANAGED
69
+
70
+
71
+ async def mcp_request(
72
+ connection: Connection,
73
+ tool: str | None = None,
74
+ arguments: dict[str, Any] | None = None,
75
+ token: str = "",
76
+ ) -> dict[str, Any]:
77
+ """Redact the token and keep HTTP exception text inside this boundary.
78
+
79
+ A connection that borrows a source passes the token it minted for the call.
80
+ """
81
+ provider = validate_mcp_connection(connection)
82
+ secret = token if borrows_source(connection) else connection.access_token
83
+ if not secret:
84
+ raise MCPError(f"This {provider.label} MCP connection is unavailable.")
85
+ try:
86
+ with anyio.fail_after(settings.OUTBOUND_TIMEOUT_SECONDS):
87
+ async with (
88
+ outbound_client(
89
+ {
90
+ **provider.headers,
91
+ "Authorization": f"{provider.auth_scheme} {secret}",
92
+ }
93
+ ) as client,
94
+ streamable_http_client(connection.endpoint_url, http_client=client) as (
95
+ reader,
96
+ writer,
97
+ session_id,
98
+ ),
99
+ ClientSession(
100
+ reader,
101
+ writer,
102
+ read_timeout_seconds=timedelta(
103
+ seconds=settings.OUTBOUND_TIMEOUT_SECONDS
104
+ ),
105
+ ) as session,
106
+ ):
107
+ del session_id
108
+ await session.initialize()
109
+ if tool is not None:
110
+ payload = await call_tool(session, tool, arguments)
111
+ else:
112
+ payload = await offered_tools(session, provider)
113
+ except Exception:
114
+ # SDK exception groups can embed request headers or remote response text.
115
+ raise MCPRequestError(
116
+ f"{provider.label} MCP request failed. Check the token, permissions, "
117
+ "and service availability."
118
+ ) from None
119
+ if tool is not None and payload.get("isError"):
120
+ # Raised outside the session so the catch-all above keeps its message.
121
+ text = error_text(payload)
122
+ if message := provider.tool_error(tool, text):
123
+ raise MCPRequestError(message)
124
+ message = f"{provider.label} could not complete this tool call."
125
+ if provider.key == Connection.Provider.MCP:
126
+ reason = text.replace(secret, "[redacted]").strip()
127
+ if reason:
128
+ raise CustomMCPToolError(message, reason[:MCP_ERROR_REASON_MAX_CHARS])
129
+ raise MCPRequestError(message)
130
+ encoded = json.dumps(payload)
131
+ if len(encoded.encode()) > RESPONSE_MAX_BYTES:
132
+ raise MCPError("The MCP result exceeded the size limit.")
133
+ return json.loads(encoded.replace(secret, "[redacted]"))
134
+
135
+
136
+ async def call_tool(
137
+ session: ClientSession,
138
+ tool: str,
139
+ arguments: dict[str, Any] | None,
140
+ ) -> dict[str, Any]:
141
+ result = await session.call_tool(tool, arguments)
142
+ return result.model_dump(mode="json", by_alias=True)
143
+
144
+
145
+ def error_text(payload: dict[str, Any]) -> str:
146
+ """Collect text blocks from a remote error before applying provider policy."""
147
+ return " ".join(
148
+ str(item.get("text", ""))
149
+ for item in payload.get("content", [])
150
+ if isinstance(item, dict) and item.get("type") == "text"
151
+ )
152
+
153
+
154
+ def offered_tool(item: Any, provider: MCPProvider) -> dict[str, Any] | None:
155
+ """A listed tool the provider lets jobs use, or None to skip it."""
156
+ read_only = bool(item.annotations and item.annotations.readOnlyHint)
157
+ allowed = provider.owner_selects_tools or item.name in provider.tools
158
+ if not allowed or (provider.require_read_only_hint and not read_only):
159
+ return None
160
+ return {
161
+ "description": item.description or item.name,
162
+ "inputSchema": provider.narrow_schema(item.name, item.inputSchema),
163
+ "readOnly": read_only,
164
+ }
165
+
166
+
167
+ async def offered_tools(
168
+ session: ClientSession, provider: MCPProvider
169
+ ) -> dict[str, Any]:
170
+ """Every usable tool, page by page, within the page and tool limits."""
171
+ tools: dict[str, Any] = {}
172
+ cursor = None
173
+ for _ in range(MCP_MAX_PAGES):
174
+ page = await session.list_tools(cursor=cursor)
175
+ for item in page.tools:
176
+ if (tool := offered_tool(item, provider)) is not None:
177
+ tools[item.name] = tool
178
+ if len(tools) > MCP_MAX_OFFERED_TOOLS:
179
+ raise MCPError("The MCP server offers too many tools.")
180
+ cursor = page.nextCursor
181
+ if not cursor:
182
+ break
183
+ else:
184
+ raise MCPError("MCP tool discovery exceeded the page limit.")
185
+ if not tools:
186
+ raise MCPError(f"{provider.label} exposed no supported tools.")
187
+ return tools
188
+
189
+
190
+ def discovery_secret(connection: Connection, provider: MCPProvider) -> str:
191
+ if not borrows_source(connection):
192
+ return ""
193
+ if provider.discovery_token is None:
194
+ raise MCPError(f"{provider.label} needs its own token.")
195
+ return provider.discovery_token(connection.workspace)
196
+
197
+
198
+ def discover_tools(connection: Connection) -> dict[str, Any]:
199
+ fresh_connection(connection)
200
+ provider = mcp_provider(connection.provider)
201
+ tools = async_to_sync(mcp_request)(
202
+ connection, token=discovery_secret(connection, provider)
203
+ )
204
+ for tool in tools.values():
205
+ schema = tool["inputSchema"]
206
+ validate_tool_schema(schema)
207
+ try:
208
+ Draft202012Validator.check_schema(schema)
209
+ except Exception:
210
+ raise MCPError("The MCP server returned an invalid tool schema.") from None
211
+ if schema.get("type") != "object":
212
+ raise MCPError("MCP tool arguments must be an object.")
213
+ return tools
214
+
215
+
216
+ def connection_target(provider: MCPProvider, cleaned: MCPSettings) -> str:
217
+ """A label safe to show: provider and scope, or just the host for custom servers."""
218
+ if provider.owner_selects_tools:
219
+ return host_label(provider.endpoint(cleaned))
220
+ return " · ".join(
221
+ part for part in (f"{provider.label} MCP", "/".join(cleaned.values())) if part
222
+ )
223
+
224
+
225
+ def select_tools(
226
+ connection: Connection,
227
+ provider: MCPProvider,
228
+ tools: dict[str, Any],
229
+ selected: list[str] | None = None,
230
+ ) -> None:
231
+ """Built-in providers use every allowed tool. Custom servers let the owner choose."""
232
+ try:
233
+ store_tools(
234
+ connection, tools, selected, owner_selects=provider.owner_selects_tools
235
+ )
236
+ except UnknownTools as error:
237
+ raise MCPError("Choose tools from the server's list.") from error
238
+
239
+
240
+ def create_mcp_connection(
241
+ workspace: Workspace,
242
+ provider_key: str,
243
+ binding_key: str,
244
+ display_name: str,
245
+ token: str,
246
+ settings: dict[str, str] | None = None,
247
+ auth: str = "token",
248
+ ) -> Connection:
249
+ """Save a connection with a pasted token, or one that borrows sources."""
250
+ provider = mcp_provider(provider_key)
251
+ if auth not in provider.auth_methods or auth not in {"token", SOURCE_AUTH}:
252
+ raise MCPError(f"{provider.label} does not accept this kind of connection.")
253
+ if auth == "token" and not re.fullmatch(provider.token_pattern, token):
254
+ raise MCPError(provider.token_hint)
255
+ if auth == SOURCE_AUTH and token:
256
+ raise MCPError("This connection uses your GitHub connections, not a token.")
257
+ cleaned = provider.clean_settings(settings or {})
258
+ if binding_taken(workspace.pk, binding_key):
259
+ raise ConnectionConflict("This binding already has a connection.")
260
+ connection = Connection(
261
+ workspace=workspace,
262
+ provider=provider.key,
263
+ kind=Connection.Kind.MCP,
264
+ binding_key=binding_key,
265
+ display_name=display_name.strip() or provider.label,
266
+ endpoint_url=provider.endpoint(cleaned),
267
+ auth_strategy=AUTH_STRATEGIES[auth],
268
+ access_token=token if auth == "token" else "",
269
+ metadata={"target": connection_target(provider, cleaned), "settings": cleaned},
270
+ )
271
+ select_tools(connection, provider, discover_tools(connection))
272
+ connection.full_clean()
273
+ try:
274
+ with transaction.atomic():
275
+ connection.save()
276
+ except IntegrityError:
277
+ raise ConnectionConflict("This binding already has a connection.") from None
278
+ return connection
279
+
280
+
281
+ def apply_credentials(
282
+ connection: Connection,
283
+ provider: MCPProvider,
284
+ token: str | None,
285
+ settings: dict[str, str] | None,
286
+ current: dict[str, str],
287
+ ) -> None:
288
+ """Take a new token, or refuse when the edit needs one or a new sign-in."""
289
+ if connection.auth_strategy == Connection.AuthStrategy.OAUTH:
290
+ if token or (settings is not None and settings != current):
291
+ raise MCPError("Sign in again to change the server or credentials.")
292
+ if connection.status != Connection.Status.CONNECTED:
293
+ raise MCPError("Sign in again to reconnect.")
294
+ elif borrows_source(connection):
295
+ if token:
296
+ raise MCPError("This connection uses your GitHub connections, not a token.")
297
+ elif token:
298
+ if not re.fullmatch(provider.token_pattern, token):
299
+ raise MCPError(provider.token_hint)
300
+ connection.access_token = token
301
+ elif connection.status == Connection.Status.REVOKED or not connection.access_token:
302
+ raise MCPError("Enter a new token to reconnect.")
303
+
304
+
305
+ def update_mcp_connection(
306
+ connection: Connection,
307
+ display_name: str | None,
308
+ token: str | None,
309
+ settings: dict[str, str] | None,
310
+ tools: list[str] | None = None,
311
+ ) -> Connection:
312
+ """Replace the token, scope, or tool choice, then rediscover before saving.
313
+
314
+ A blank token keeps the current one, unless the connection was revoked.
315
+ Signed-in connections change their token or server only by signing in again.
316
+ """
317
+ if connection.kind != Connection.Kind.MCP:
318
+ raise ConnectionConflict("Only MCP connections can be edited here.")
319
+ provider = mcp_provider(connection.provider)
320
+ if tools is not None and not provider.owner_selects_tools:
321
+ raise MCPError(f"{provider.label} tools are fixed by SecondShift.")
322
+ current = connection.settings
323
+ apply_credentials(connection, provider, token, settings, current)
324
+ cleaned = provider.clean_settings(current if settings is None else settings)
325
+ if display_name is not None:
326
+ connection.display_name = display_name.strip() or provider.label
327
+ connection.endpoint_url = provider.endpoint(cleaned)
328
+ connection.status = Connection.Status.CONNECTED
329
+ connection.metadata = {
330
+ **connection.metadata,
331
+ "target": connection_target(provider, cleaned),
332
+ "settings": cleaned,
333
+ }
334
+ # Raises before saving, so a bad token or scope leaves the stored one alone.
335
+ offered = discover_tools(connection)
336
+ if tools is None and provider.owner_selects_tools:
337
+ tools = [name for name in connection.available_tools if name in offered]
338
+ select_tools(connection, provider, offered, tools)
339
+ connection.full_clean()
340
+ connection.save()
341
+ return connection
342
+
343
+
344
+ def recheck_mcp_connection(connection: Connection) -> str:
345
+ """List the server's tools again and keep the chosen ones it still offers."""
346
+ provider = mcp_provider(connection.provider)
347
+ tools = discover_tools(connection)
348
+ kept = [name for name in connection.available_tools if name in tools]
349
+ select_tools(
350
+ connection, provider, tools, kept if provider.owner_selects_tools else None
351
+ )
352
+ connection.save(update_fields=["available_tools", "metadata", "updated_at"])
353
+ return f"Discovered {len(tools)} supported {provider.label} tools."
@@ -0,0 +1,86 @@
1
+ """Any MCP server the owner names. SecondShift cannot scope what it reaches."""
2
+
3
+ from typing import Any
4
+
5
+ from secondshift.connections.access import AccessReason
6
+ from secondshift.connections.mcp.base import (
7
+ MCPError,
8
+ MCPProvider,
9
+ MCPSetting,
10
+ MCPSettings,
11
+ )
12
+ from secondshift.connections.models import Connection
13
+ from secondshift.connections.outbound import (
14
+ OutboundUrlError,
15
+ host_label,
16
+ validate_outbound_url,
17
+ )
18
+ from secondshift.connections.repository_ref import RepositoryRef
19
+
20
+ URL = "url"
21
+ # Connection.endpoint_url is a URLField with Django's default limit.
22
+ CUSTOM_URL_MAX_LENGTH = 200
23
+ OAUTH = "oauth"
24
+ TOKEN = "token"
25
+
26
+
27
+ def custom_server_url(value: str) -> str:
28
+ """HTTPS only, no credentials in the URL. DNS is checked when connecting."""
29
+ try:
30
+ return validate_outbound_url(value, "server URL", resolve=False)
31
+ except OutboundUrlError as error:
32
+ raise MCPError(str(error)) from None
33
+
34
+
35
+ def custom_scope(settings: MCPSettings, repository: RepositoryRef) -> str | None:
36
+ del repository
37
+ url = settings.get(URL)
38
+ return host_label(url) if url else None
39
+
40
+
41
+ def custom_arguments(
42
+ scope: str, tool: str, arguments: dict[str, Any]
43
+ ) -> dict[str, Any]:
44
+ # The owner chose the tools. Schema checks still run in the broker.
45
+ del scope, tool
46
+ return dict(arguments)
47
+
48
+
49
+ def describe_custom_scope(scope: str) -> str:
50
+ return f"anything the credential can reach on {scope}"
51
+
52
+
53
+ CUSTOM_MCP = MCPProvider(
54
+ key=Connection.Provider.MCP,
55
+ label="Custom server",
56
+ endpoint_url="",
57
+ tools=(),
58
+ scope=custom_scope,
59
+ scope_arguments=custom_arguments,
60
+ describe_scope=describe_custom_scope,
61
+ access_verb="use",
62
+ scope_label="server",
63
+ missing_scope_reason=AccessReason.MCP_SERVER_UNKNOWN,
64
+ missing_scope_message="This MCP connection has no server URL.",
65
+ settings=(
66
+ MCPSetting(
67
+ key=URL,
68
+ label="Server URL",
69
+ pattern=r"https?://\S+",
70
+ max_length=CUSTOM_URL_MAX_LENGTH,
71
+ help="The server's MCP endpoint, usually ending in /mcp.",
72
+ placeholder="https://mcp.example.com/mcp",
73
+ validate=custom_server_url,
74
+ ),
75
+ ),
76
+ endpoint_path=lambda settings: settings.get(URL, ""),
77
+ owner_selects_tools=True,
78
+ auth_methods=(OAUTH, TOKEN),
79
+ token_hint="Enter a bearer token without whitespace.",
80
+ description=(
81
+ "Connect any MCP server that supports sign-in or a bearer token. "
82
+ "You choose which tools jobs may use."
83
+ ),
84
+ token_label="Bearer token",
85
+ setup_help="Sent as Authorization: Bearer <token>.",
86
+ )
@@ -0,0 +1,256 @@
1
+ """GitHub's hosted MCP server: read-only tools, one repository per job."""
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from secondshift.config.constants import (
7
+ GITHUB_BASE_URL,
8
+ GITHUB_FINE_GRAINED_TOKEN_PATTERN,
9
+ GITHUB_MCP_TOOLS,
10
+ GITHUB_MCP_URL,
11
+ GITHUB_REPOSITORY_PATTERN,
12
+ GITHUB_TOKEN_HINT,
13
+ GitHubMCPTool,
14
+ )
15
+ from secondshift.connections.access import AccessReason
16
+ from secondshift.connections.dao import connected_github_sources
17
+ from secondshift.connections.github import connection_source, usable_source
18
+ from secondshift.connections.mcp.base import (
19
+ SOURCE_AUTH,
20
+ MCPArgumentError,
21
+ MCPError,
22
+ MCPProvider,
23
+ MCPSettings,
24
+ )
25
+ from secondshift.connections.models import Connection
26
+ from secondshift.connections.repository_ref import RepositoryRef
27
+ from secondshift.github import credentials
28
+ from secondshift.github.errors import GitHubError
29
+ from secondshift.workspaces.models import Workspace
30
+
31
+ # Search text is written by the model. A short limit keeps it a search.
32
+ GITHUB_SEARCH_QUERY_MAX_CHARS = 256
33
+ # Artifacts and log archives are left out. They return download links.
34
+ GITHUB_ACTIONS_METHODS = {
35
+ GitHubMCPTool.ACTIONS_LIST: frozenset(
36
+ {"list_workflows", "list_workflow_runs", "list_workflow_jobs"}
37
+ ),
38
+ GitHubMCPTool.ACTIONS_GET: frozenset(
39
+ {"get_workflow", "get_workflow_run", "get_workflow_job"}
40
+ ),
41
+ }
42
+ GITHUB_ACTIONS_TOOLS = frozenset(
43
+ {GitHubMCPTool.ACTIONS_LIST, GitHubMCPTool.ACTIONS_GET, GitHubMCPTool.GET_JOB_LOGS}
44
+ )
45
+ GITHUB_MISSING_SOURCE = (
46
+ "This repository was not added from GitHub, so it has no GitHub connection "
47
+ "to borrow. Use a GitHub connection with its own token."
48
+ )
49
+ # The end of a failed job's log usually holds the error.
50
+ GITHUB_JOB_LOG_MAX_LINES = 300
51
+
52
+
53
+ def scoped_search_query(repository: str, query: str) -> str:
54
+ """Pin a pull request search to the job's repository."""
55
+ if len(query) > GITHUB_SEARCH_QUERY_MAX_CHARS:
56
+ raise MCPArgumentError(
57
+ f"Keep the search under {GITHUB_SEARCH_QUERY_MAX_CHARS} characters."
58
+ )
59
+ # OR can widen a repo-qualified search. Disallow it rather than attempt
60
+ # to rewrite the provider's query language. GitHub only reads it in capitals.
61
+ if re.search(r"\bOR\b", query):
62
+ raise MCPArgumentError("Use separate searches instead of OR.")
63
+ query = re.sub(rf"(?i)(?<!\S)repo:{re.escape(repository)}(?=\s|$)", "", query)
64
+ if re.search(r"repo\s*:", query, re.IGNORECASE):
65
+ raise MCPArgumentError("The search must target the approved repository.")
66
+ return f"repo:{repository} {query.strip()}".strip()
67
+
68
+
69
+ def job_log_lines(requested: Any) -> int:
70
+ """The requested log tail, capped. Anything else gets the cap."""
71
+ if not isinstance(requested, int) or isinstance(requested, bool) or requested < 1:
72
+ return GITHUB_JOB_LOG_MAX_LINES
73
+ return min(requested, GITHUB_JOB_LOG_MAX_LINES)
74
+
75
+
76
+ def scoped_github_arguments(
77
+ repository: str, tool: str, arguments: dict[str, Any]
78
+ ) -> dict[str, Any]:
79
+ """Enforce the owner's repository scope independently of model instructions."""
80
+ if tool not in GITHUB_MCP_TOOLS:
81
+ raise MCPError("This GitHub tool is not supported.")
82
+ if not re.fullmatch(GITHUB_REPOSITORY_PATTERN, repository):
83
+ raise MCPError("The job has no valid GitHub repository scope.")
84
+ owner, repo = repository.split("/", 1)
85
+ scoped = dict(arguments)
86
+ for key, value in (("owner", owner), ("repo", repo)):
87
+ if key in scoped and scoped[key] != value:
88
+ raise MCPArgumentError("The tool must target the approved repository.")
89
+ scoped[key] = value
90
+ if tool == GitHubMCPTool.LIST_COMMITS and scoped.get("sha"):
91
+ raise MCPArgumentError("List commits from the default branch by omitting sha.")
92
+ if tool == GitHubMCPTool.SEARCH_PULL_REQUESTS:
93
+ scoped["query"] = scoped_search_query(repository, str(scoped.get("query", "")))
94
+ methods = GITHUB_ACTIONS_METHODS.get(tool)
95
+ if methods is not None and scoped.get("method") not in methods:
96
+ raise MCPArgumentError(
97
+ f"Use one of these methods: {', '.join(sorted(methods))}."
98
+ )
99
+ if tool == GitHubMCPTool.GET_JOB_LOGS:
100
+ # A download link would skip the broker's size limit and redaction.
101
+ scoped["return_content"] = True
102
+ scoped["tail_lines"] = job_log_lines(scoped.get("tail_lines"))
103
+ return scoped
104
+
105
+
106
+ def narrowed_github_schema(tool: str, schema: dict[str, Any]) -> dict[str, Any]:
107
+ """Show the model only the Actions methods and log options it may use.
108
+
109
+ A call outside the scope rules stops the run, so the model must not be
110
+ offered methods those rules block.
111
+ """
112
+ properties = schema.get("properties")
113
+ if not isinstance(properties, dict):
114
+ return schema
115
+ narrowed = {**schema, "properties": dict(properties)}
116
+ methods = GITHUB_ACTIONS_METHODS.get(tool)
117
+ if methods is not None and isinstance(properties.get("method"), dict):
118
+ narrowed["properties"]["method"] = {
119
+ **properties["method"],
120
+ "enum": sorted(methods),
121
+ }
122
+ if tool == GitHubMCPTool.GET_JOB_LOGS:
123
+ # Always set by scope_arguments, so the model has no choice to make.
124
+ narrowed["properties"].pop("return_content", None)
125
+ if isinstance(narrowed.get("required"), list):
126
+ narrowed["required"] = [
127
+ name for name in narrowed["required"] if name != "return_content"
128
+ ]
129
+ if isinstance(properties.get("tail_lines"), dict):
130
+ # GitHub's own default is above the cap.
131
+ narrowed["properties"]["tail_lines"] = {
132
+ **{
133
+ key: value
134
+ for key, value in properties["tail_lines"].items()
135
+ if key != "default"
136
+ },
137
+ "description": (
138
+ "Lines to return from the end of the log. "
139
+ f"At most {GITHUB_JOB_LOG_MAX_LINES}."
140
+ ),
141
+ }
142
+ return narrowed
143
+
144
+
145
+ def github_tool_error(tool: str, text: str) -> str | None:
146
+ """Name the missing token permission when GitHub refuses a call."""
147
+ if not re.search(r"\b403\b|Resource not accessible", text):
148
+ return None
149
+ if tool in GITHUB_ACTIONS_TOOLS:
150
+ return (
151
+ "GitHub refused this call. It needs read access to Actions on this "
152
+ "repository. Give it to the token or the GitHub App, then retry."
153
+ )
154
+ return (
155
+ "GitHub refused this call. Check the repository permissions of the "
156
+ "token or the GitHub App."
157
+ )
158
+
159
+
160
+ def github_scope(settings: MCPSettings, repository: RepositoryRef) -> str | None:
161
+ """A job reads only its own repository, whatever the token can reach."""
162
+ del settings
163
+ return repository.github_name
164
+
165
+
166
+ # What the tools read. An App token asks only for those the installation has.
167
+ GITHUB_TOOL_PERMISSIONS = ("contents", "pull_requests", "actions", "statuses")
168
+
169
+
170
+ def github_source_reachable(repository: RepositoryRef) -> bool:
171
+ connection = repository.source
172
+ return (
173
+ repository.github_id is not None
174
+ and usable_source(connection)
175
+ and connection.workspace_id == repository.workspace_id
176
+ )
177
+
178
+
179
+ def github_source_token(repository: RepositoryRef) -> str:
180
+ """A read token for this repository only, from the source it was added with."""
181
+ connection = repository.source
182
+ if repository.github_id is None or connection is None:
183
+ raise MCPError(GITHUB_MISSING_SOURCE)
184
+ if connection.workspace_id != repository.workspace_id:
185
+ raise MCPError("The repository's GitHub connection is disconnected.")
186
+ try:
187
+ return credentials.read_token_for(
188
+ connection_source(connection),
189
+ repository.github_id,
190
+ GITHUB_TOOL_PERMISSIONS,
191
+ )
192
+ except GitHubError as error:
193
+ # These messages never contain a token.
194
+ raise MCPError(str(error)) from error
195
+
196
+
197
+ def github_discovery_token(workspace: Workspace) -> str:
198
+ """Any source connection of the workspace can list GitHub's tools."""
199
+ for connection in connected_github_sources(workspace.pk):
200
+ if not usable_source(connection):
201
+ continue
202
+ try:
203
+ return credentials.read_token_for(
204
+ connection_source(connection), None, GITHUB_TOOL_PERMISSIONS
205
+ )
206
+ except GitHubError:
207
+ continue
208
+ raise MCPError(
209
+ "Connect GitHub on the Repositories page first, with the GitHub App or a token."
210
+ )
211
+
212
+
213
+ def describe_github_scope(scope: str) -> str:
214
+ return f"the {scope} repository"
215
+
216
+
217
+ GITHUB_MCP = MCPProvider(
218
+ key=Connection.Provider.GITHUB,
219
+ label="GitHub",
220
+ endpoint_url=GITHUB_MCP_URL,
221
+ tools=GITHUB_MCP_TOOLS,
222
+ headers={
223
+ "X-MCP-Tools": ",".join(GITHUB_MCP_TOOLS),
224
+ "X-MCP-Readonly": "true",
225
+ },
226
+ token_pattern=GITHUB_FINE_GRAINED_TOKEN_PATTERN,
227
+ token_hint=GITHUB_TOKEN_HINT,
228
+ scope=github_scope,
229
+ describe_scope=describe_github_scope,
230
+ scope_label="repository",
231
+ missing_scope_reason=AccessReason.GITHUB_REPOSITORY_UNKNOWN,
232
+ missing_scope_message=(
233
+ "This job has no GitHub repository identity. For a local checkout, "
234
+ "configure a GitHub origin and resync it."
235
+ ),
236
+ description=(
237
+ "Read pull requests, commits, and Actions runs and logs. "
238
+ "Each job reads only its own repository."
239
+ ),
240
+ default_binding="github",
241
+ token_label="Fine-grained personal access token",
242
+ setup_url=f"{GITHUB_BASE_URL}/settings/personal-access-tokens/new",
243
+ setup_help=(
244
+ "Pick your jobs' repositories and grant read-only Pull requests, "
245
+ "Commit statuses, and Contents. Add read-only Actions for jobs that "
246
+ "read CI runs and logs."
247
+ ),
248
+ auth_methods=("token", SOURCE_AUTH),
249
+ source_token=github_source_token,
250
+ discovery_token=github_discovery_token,
251
+ source_reachable=github_source_reachable,
252
+ missing_source_message=GITHUB_MISSING_SOURCE,
253
+ scope_arguments=scoped_github_arguments,
254
+ narrow_schema=narrowed_github_schema,
255
+ tool_error=github_tool_error,
256
+ )