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.
- secondshift/__init__.py +1 -0
- secondshift/__main__.py +3 -0
- secondshift/cli.py +17 -0
- secondshift/config/__init__.py +0 -0
- secondshift/config/api.py +23 -0
- secondshift/config/app.py +62 -0
- secondshift/config/constants.py +279 -0
- secondshift/config/container_env.py +118 -0
- secondshift/config/first_run.py +40 -0
- secondshift/config/formatting.py +5 -0
- secondshift/config/gunicorn.py +31 -0
- secondshift/config/logging.py +40 -0
- secondshift/config/settings/__init__.py +0 -0
- secondshift/config/settings/base.py +524 -0
- secondshift/config/settings/dev.py +14 -0
- secondshift/config/settings/local.py +23 -0
- secondshift/config/settings/production.py +113 -0
- secondshift/config/settings/runner.py +140 -0
- secondshift/config/settings/test.py +47 -0
- secondshift/config/static_storage.py +45 -0
- secondshift/config/urls.py +42 -0
- secondshift/connections/__init__.py +0 -0
- secondshift/connections/access.py +27 -0
- secondshift/connections/admin.py +25 -0
- secondshift/connections/agents.py +224 -0
- secondshift/connections/api.py +747 -0
- secondshift/connections/apps.py +6 -0
- secondshift/connections/catalog.py +96 -0
- secondshift/connections/checks.py +22 -0
- secondshift/connections/dao.py +305 -0
- secondshift/connections/errors.py +13 -0
- secondshift/connections/execution.py +262 -0
- secondshift/connections/external.py +258 -0
- secondshift/connections/github.py +123 -0
- secondshift/connections/http_api/__init__.py +1 -0
- secondshift/connections/http_api/client.py +161 -0
- secondshift/connections/http_api/connections.py +187 -0
- secondshift/connections/http_api/spec.py +382 -0
- secondshift/connections/mcp/__init__.py +1 -0
- secondshift/connections/mcp/base.py +139 -0
- secondshift/connections/mcp/client.py +353 -0
- secondshift/connections/mcp/custom.py +86 -0
- secondshift/connections/mcp/github.py +256 -0
- secondshift/connections/mcp/oauth.py +352 -0
- secondshift/connections/mcp/providers.py +15 -0
- secondshift/connections/mcp/schemas.py +77 -0
- secondshift/connections/mcp/sentry.py +135 -0
- secondshift/connections/mcp/signin.py +206 -0
- secondshift/connections/migration_helpers.py +28 -0
- secondshift/connections/migrations/0001_initial.py +101 -0
- secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
- secondshift/connections/migrations/0003_require_approval.py +23 -0
- secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
- secondshift/connections/migrations/0005_agentconnection.py +36 -0
- secondshift/connections/migrations/0006_remove_is_default.py +23 -0
- secondshift/connections/migrations/0007_agent_last_check.py +18 -0
- secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
- secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
- secondshift/connections/migrations/0010_connection_attribution.py +18 -0
- secondshift/connections/migrations/__init__.py +0 -0
- secondshift/connections/model_providers.py +441 -0
- secondshift/connections/models.py +246 -0
- secondshift/connections/outbound.py +317 -0
- secondshift/connections/repository_ref.py +19 -0
- secondshift/connections/schemas.py +313 -0
- secondshift/connections/serialization.py +100 -0
- secondshift/connections/services.py +94 -0
- secondshift/connections/tools.py +38 -0
- secondshift/connections/webhooks.py +107 -0
- secondshift/core/__init__.py +0 -0
- secondshift/core/adapters.py +38 -0
- secondshift/core/admin.py +5 -0
- secondshift/core/api.py +104 -0
- secondshift/core/apps.py +20 -0
- secondshift/core/launcher.py +311 -0
- secondshift/core/local_agents.py +90 -0
- secondshift/core/local_output.py +120 -0
- secondshift/core/management/__init__.py +0 -0
- secondshift/core/management/commands/__init__.py +0 -0
- secondshift/core/management/commands/bootstrap_dev.py +33 -0
- secondshift/core/management/commands/dev_reset_db.py +204 -0
- secondshift/core/management/commands/login.py +23 -0
- secondshift/core/management/commands/logs.py +58 -0
- secondshift/core/management/commands/status.py +46 -0
- secondshift/core/management/commands/up.py +244 -0
- secondshift/core/middleware.py +21 -0
- secondshift/core/migrations/__init__.py +0 -0
- secondshift/core/schemas.py +71 -0
- secondshift/core/signin.py +58 -0
- secondshift/core/static/core/account.css +149 -0
- secondshift/core/static/core/admin.css +135 -0
- secondshift/core/templatetags/__init__.py +0 -0
- secondshift/core/templatetags/accounts.py +20 -0
- secondshift/core/terminal.py +27 -0
- secondshift/core/views.py +10 -0
- secondshift/gateway/__init__.py +0 -0
- secondshift/gateway/apps.py +6 -0
- secondshift/gateway/dao.py +137 -0
- secondshift/gateway/metering.py +171 -0
- secondshift/gateway/migrations/0001_initial.py +54 -0
- secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
- secondshift/gateway/migrations/__init__.py +0 -0
- secondshift/gateway/models.py +61 -0
- secondshift/gateway/pricing.py +39 -0
- secondshift/gateway/proxy.py +369 -0
- secondshift/gateway/tokens.py +68 -0
- secondshift/gateway/urls.py +17 -0
- secondshift/gateway/views.py +11 -0
- secondshift/github/__init__.py +6 -0
- secondshift/github/app.py +109 -0
- secondshift/github/authors.py +56 -0
- secondshift/github/credentials.py +59 -0
- secondshift/github/errors.py +2 -0
- secondshift/github/git.py +56 -0
- secondshift/github/identity.py +35 -0
- secondshift/github/installations.py +74 -0
- secondshift/github/issues.py +75 -0
- secondshift/github/permissions.py +49 -0
- secondshift/github/pulls.py +49 -0
- secondshift/github/repos.py +52 -0
- secondshift/github/rest.py +93 -0
- secondshift/github/ruff.toml +16 -0
- secondshift/github/tokens.py +52 -0
- secondshift/jobs/__init__.py +0 -0
- secondshift/jobs/access.py +12 -0
- secondshift/jobs/admin.py +83 -0
- secondshift/jobs/api.py +184 -0
- secondshift/jobs/apps.py +6 -0
- secondshift/jobs/automatic_grants.py +115 -0
- secondshift/jobs/autosync.py +78 -0
- secondshift/jobs/capabilities.py +43 -0
- secondshift/jobs/dao.py +258 -0
- secondshift/jobs/drafts.py +112 -0
- secondshift/jobs/grants.py +116 -0
- secondshift/jobs/limits.py +26 -0
- secondshift/jobs/management/__init__.py +0 -0
- secondshift/jobs/management/commands/__init__.py +0 -0
- secondshift/jobs/manifest.py +364 -0
- secondshift/jobs/migrations/0001_initial.py +87 -0
- secondshift/jobs/migrations/0002_job_history.py +18 -0
- secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
- secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
- secondshift/jobs/migrations/0005_job_triggers.py +18 -0
- secondshift/jobs/migrations/0006_job_webhook.py +18 -0
- secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
- secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
- secondshift/jobs/migrations/0009_job_subagents.py +18 -0
- secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
- secondshift/jobs/migrations/__init__.py +0 -0
- secondshift/jobs/models.py +199 -0
- secondshift/jobs/needs.py +154 -0
- secondshift/jobs/plan.py +176 -0
- secondshift/jobs/planning.py +386 -0
- secondshift/jobs/readiness.py +46 -0
- secondshift/jobs/repository_sync.py +199 -0
- secondshift/jobs/scheduling.py +26 -0
- secondshift/jobs/schemas.py +207 -0
- secondshift/jobs/serialization.py +218 -0
- secondshift/jobs/skill_needs.py +189 -0
- secondshift/jobs/sync.py +252 -0
- secondshift/jobs/tool_policy.py +82 -0
- secondshift/jobs/tool_rules.py +200 -0
- secondshift/mcp_server/__init__.py +6 -0
- secondshift/mcp_server/api.py +79 -0
- secondshift/mcp_server/apps.py +7 -0
- secondshift/mcp_server/audit.py +22 -0
- secondshift/mcp_server/core.py +12 -0
- secondshift/mcp_server/dao.py +23 -0
- secondshift/mcp_server/migrations/0001_initial.py +33 -0
- secondshift/mcp_server/migrations/__init__.py +0 -0
- secondshift/mcp_server/models.py +40 -0
- secondshift/mcp_server/schemas.py +204 -0
- secondshift/mcp_server/tools/__init__.py +9 -0
- secondshift/mcp_server/tools/jobs.py +106 -0
- secondshift/mcp_server/tools/manifests.py +127 -0
- secondshift/mcp_server/tools/repositories.py +42 -0
- secondshift/mcp_server/tools/runs.py +86 -0
- secondshift/mcp_server/tools/tasks.py +103 -0
- secondshift/mcp_server/tools_registry.py +81 -0
- secondshift/repositories/__init__.py +0 -0
- secondshift/repositories/access.py +13 -0
- secondshift/repositories/admin.py +19 -0
- secondshift/repositories/api.py +296 -0
- secondshift/repositories/apps.py +6 -0
- secondshift/repositories/dao.py +114 -0
- secondshift/repositories/external_skills.py +224 -0
- secondshift/repositories/github.py +27 -0
- secondshift/repositories/github_access.py +57 -0
- secondshift/repositories/github_check.py +279 -0
- secondshift/repositories/github_sync.py +181 -0
- secondshift/repositories/github_views.py +90 -0
- secondshift/repositories/local.py +198 -0
- secondshift/repositories/management/__init__.py +0 -0
- secondshift/repositories/management/commands/__init__.py +0 -0
- secondshift/repositories/management/commands/prune_source_cache.py +15 -0
- secondshift/repositories/migrations/0001_initial.py +45 -0
- secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
- secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
- secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
- secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
- secondshift/repositories/migrations/0006_allow_issues.py +18 -0
- secondshift/repositories/migrations/__init__.py +0 -0
- secondshift/repositories/mirrors.py +50 -0
- secondshift/repositories/models.py +104 -0
- secondshift/repositories/pull_requests.py +199 -0
- secondshift/repositories/schemas.py +102 -0
- secondshift/repositories/serialization.py +133 -0
- secondshift/repositories/skill_source.py +64 -0
- secondshift/repositories/snapshots.py +238 -0
- secondshift/repositories/sources.py +256 -0
- secondshift/repositories/status.py +67 -0
- secondshift/runner/README.md +65 -0
- secondshift/runner/__init__.py +1 -0
- secondshift/runner/agent_credentials.py +89 -0
- secondshift/runner/agent_image/Dockerfile +16 -0
- secondshift/runner/agent_image/agent.py +132 -0
- secondshift/runner/agent_image/claude.py +254 -0
- secondshift/runner/agent_image/codex.py +84 -0
- secondshift/runner/agent_image/outcomes.py +120 -0
- secondshift/runner/agent_image/subagent_hook.py +71 -0
- secondshift/runner/agent_protocol.py +41 -0
- secondshift/runner/agents.py +254 -0
- secondshift/runner/api.py +123 -0
- secondshift/runner/apps.py +6 -0
- secondshift/runner/client.py +168 -0
- secondshift/runner/collection.py +276 -0
- secondshift/runner/dao.py +64 -0
- secondshift/runner/dev.py +19 -0
- secondshift/runner/images.py +57 -0
- secondshift/runner/migrations/0001_initial.py +48 -0
- secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
- secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
- secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
- secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
- secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
- secondshift/runner/migrations/__init__.py +0 -0
- secondshift/runner/models.py +53 -0
- secondshift/runner/protocol.py +97 -0
- secondshift/runner/schemas.py +36 -0
- secondshift/runner/services.py +465 -0
- secondshift/runner/sources.py +130 -0
- secondshift/runner/urls.py +7 -0
- secondshift/runs/__init__.py +0 -0
- secondshift/runs/access.py +13 -0
- secondshift/runs/admin.py +89 -0
- secondshift/runs/api.py +211 -0
- secondshift/runs/apps.py +11 -0
- secondshift/runs/artifacts.py +261 -0
- secondshift/runs/dao.py +640 -0
- secondshift/runs/deliveries.py +80 -0
- secondshift/runs/execution/__init__.py +0 -0
- secondshift/runs/execution/access_needs.py +89 -0
- secondshift/runs/execution/agent.py +91 -0
- secondshift/runs/execution/agent_broker.py +198 -0
- secondshift/runs/execution/budgets.py +177 -0
- secondshift/runs/execution/cli_agent.py +167 -0
- secondshift/runs/execution/consent.py +58 -0
- secondshift/runs/execution/deadlines.py +47 -0
- secondshift/runs/execution/external_tools.py +84 -0
- secondshift/runs/execution/issues.py +99 -0
- secondshift/runs/execution/model_gateway.py +90 -0
- secondshift/runs/execution/payload.py +63 -0
- secondshift/runs/execution/result.py +59 -0
- secondshift/runs/execution/run.py +345 -0
- secondshift/runs/execution/subagents.py +246 -0
- secondshift/runs/execution/tool_broker.py +123 -0
- secondshift/runs/execution/workspace_tools.py +248 -0
- secondshift/runs/history.py +182 -0
- secondshift/runs/inputs.py +49 -0
- secondshift/runs/issue_delivery.py +144 -0
- secondshift/runs/management/__init__.py +0 -0
- secondshift/runs/management/commands/__init__.py +0 -0
- secondshift/runs/management/commands/prune_runs.py +12 -0
- secondshift/runs/management/commands/run_dbos_worker.py +129 -0
- secondshift/runs/migration_helpers.py +313 -0
- secondshift/runs/migrations/0001_initial.py +69 -0
- secondshift/runs/migrations/0002_run_started_by.py +18 -0
- secondshift/runs/migrations/0003_agentlease.py +25 -0
- secondshift/runs/migrations/0004_task_assignment.py +39 -0
- secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
- secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
- secondshift/runs/migrations/0007_run_delivery.py +35 -0
- secondshift/runs/migrations/0008_run_outputs.py +18 -0
- secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
- secondshift/runs/migrations/0010_run_issue.py +32 -0
- secondshift/runs/migrations/0011_issues_delivery.py +23 -0
- secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
- secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
- secondshift/runs/migrations/0014_run_access_need.py +30 -0
- secondshift/runs/migrations/__init__.py +0 -0
- secondshift/runs/models.py +340 -0
- secondshift/runs/orchestration/__init__.py +0 -0
- secondshift/runs/orchestration/admission.py +15 -0
- secondshift/runs/orchestration/dbos_runtime.py +43 -0
- secondshift/runs/orchestration/dispatch.py +130 -0
- secondshift/runs/orchestration/periodic.py +39 -0
- secondshift/runs/orchestration/reconciliation.py +98 -0
- secondshift/runs/orchestration/schedules.py +51 -0
- secondshift/runs/orchestration/workflows.py +480 -0
- secondshift/runs/outputs.py +52 -0
- secondshift/runs/publishing.py +162 -0
- secondshift/runs/queue.py +26 -0
- secondshift/runs/reporting/__init__.py +0 -0
- secondshift/runs/reporting/collection.py +26 -0
- secondshift/runs/reporting/connection_usage.py +45 -0
- secondshift/runs/reporting/failures.py +150 -0
- secondshift/runs/reporting/health.py +158 -0
- secondshift/runs/reporting/notifications.py +258 -0
- secondshift/runs/reporting/queries.py +60 -0
- secondshift/runs/retention.py +35 -0
- secondshift/runs/schemas.py +192 -0
- secondshift/runs/serialization.py +245 -0
- secondshift/runs/services.py +176 -0
- secondshift/runs/signals.py +25 -0
- secondshift/runs/submissions.py +236 -0
- secondshift/runs/transitions.py +84 -0
- secondshift/templates/account/password_reset_help_text.html +1 -0
- secondshift/templates/admin/base_site.html +24 -0
- secondshift/templates/allauth/elements/provider.html +9 -0
- secondshift/templates/allauth/layouts/base.html +67 -0
- secondshift/templates/socialaccount/snippets/login.html +9 -0
- secondshift/templates/workspaces/invite_unusable.html +10 -0
- secondshift/triggers/__init__.py +0 -0
- secondshift/triggers/admin.py +19 -0
- secondshift/triggers/api.py +116 -0
- secondshift/triggers/apps.py +6 -0
- secondshift/triggers/dao.py +66 -0
- secondshift/triggers/filters.py +48 -0
- secondshift/triggers/migrations/0001_initial.py +55 -0
- secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
- secondshift/triggers/migrations/__init__.py +0 -0
- secondshift/triggers/models.py +83 -0
- secondshift/triggers/schemas.py +58 -0
- secondshift/triggers/serialization.py +91 -0
- secondshift/triggers/services.py +246 -0
- secondshift/triggers/verification.py +84 -0
- secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
- secondshift/web/assets/index-D27gB9_L.css +1 -0
- secondshift/web/favicon.svg +19 -0
- secondshift/web/index.html +16 -0
- secondshift/web/secondshift-mark.svg +16 -0
- secondshift/workspaces/__init__.py +0 -0
- secondshift/workspaces/access.py +181 -0
- secondshift/workspaces/admin.py +37 -0
- secondshift/workspaces/api.py +264 -0
- secondshift/workspaces/apps.py +6 -0
- secondshift/workspaces/context_processors.py +11 -0
- secondshift/workspaces/dao.py +281 -0
- secondshift/workspaces/invites.py +150 -0
- secondshift/workspaces/limits.py +197 -0
- secondshift/workspaces/management/__init__.py +0 -0
- secondshift/workspaces/management/commands/__init__.py +0 -0
- secondshift/workspaces/management/commands/create_workspace.py +39 -0
- secondshift/workspaces/migrations/0001_initial.py +67 -0
- secondshift/workspaces/migrations/0002_api_token.py +34 -0
- secondshift/workspaces/migrations/0003_execution_default.py +29 -0
- secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
- secondshift/workspaces/migrations/__init__.py +0 -0
- secondshift/workspaces/models.py +169 -0
- secondshift/workspaces/owners.py +58 -0
- secondshift/workspaces/schemas.py +149 -0
- secondshift/workspaces/serialization.py +138 -0
- secondshift/workspaces/server_settings.py +140 -0
- secondshift/workspaces/services.py +94 -0
- secondshift/workspaces/tokens.py +108 -0
- secondshift/workspaces/views.py +32 -0
- secondshift-0.1.0b12.dist-info/METADATA +270 -0
- secondshift-0.1.0b12.dist-info/RECORD +371 -0
- secondshift-0.1.0b12.dist-info/WHEEL +4 -0
- secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
- 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
|