diff --git a/products/tasks/backend/facade/api.py b/products/tasks/backend/facade/api.py index ab3a86bd9776..bcc151deb2b6 100644 --- a/products/tasks/backend/facade/api.py +++ b/products/tasks/backend/facade/api.py @@ -2877,6 +2877,8 @@ def bootstrap_task_run( github_user_token = validated_data.get("github_user_token") initial_permission_mode = validated_data.get("initial_permission_mode") home_quick_action = validated_data.get("home_quick_action") + imported_mcp_servers = validated_data.get("imported_mcp_servers") + relayed_mcp_servers = validated_data.get("relayed_mcp_servers") if run_source == RunSource.SIGNAL_REPORT: pr_authorship_mode = PrAuthorshipMode.BOT @@ -2975,6 +2977,17 @@ def bootstrap_task_run( ) run = task.create_run(environment=environment, mode=mode, branch=branch, extra_state=extra_state) + if imported_mcp_servers or relayed_mcp_servers: + update_fields = ["updated_at"] + if imported_mcp_servers: + # Kept out of `state` (a plain JSONField) because header values carry credentials. + run.imported_mcp_servers = imported_mcp_servers + update_fields.append("imported_mcp_servers") + if relayed_mcp_servers: + run.relayed_mcp_servers = relayed_mcp_servers + update_fields.append("relayed_mcp_servers") + run.save(update_fields=update_fields) + if github_user_token and pr_authorship_mode == PrAuthorshipMode.USER: cache_github_user_token(str(run.id), github_user_token) @@ -4104,6 +4117,8 @@ def run_task( reasoning_effort = validated_data.get("reasoning_effort") github_user_token = validated_data.get("github_user_token") initial_permission_mode = validated_data.get("initial_permission_mode") + imported_mcp_servers = validated_data.get("imported_mcp_servers") + relayed_mcp_servers = validated_data.get("relayed_mcp_servers") if run_source == RunSource.SIGNAL_REPORT: pr_authorship_mode = PrAuthorshipMode.BOT @@ -4280,6 +4295,17 @@ def run_task( logger.info("Creating task run for task %s with mode=%s, branch=%s", task.id, mode, branch) task_run = task.create_run(mode=mode, branch=branch, extra_state=extra_state) + if imported_mcp_servers or relayed_mcp_servers: + update_fields = ["updated_at"] + if imported_mcp_servers: + # Kept out of `state` (a plain JSONField) because header values carry credentials. + task_run.imported_mcp_servers = imported_mcp_servers + update_fields.append("imported_mcp_servers") + if relayed_mcp_servers: + task_run.relayed_mcp_servers = relayed_mcp_servers + update_fields.append("relayed_mcp_servers") + task_run.save(update_fields=update_fields) + if pending_user_artifact_ids: _attach_staged_artifacts_to_run( task_run, task, staged_artifacts=staged_artifacts, artifact_ids=pending_user_artifact_ids diff --git a/products/tasks/backend/logic/services/docker_sandbox.py b/products/tasks/backend/logic/services/docker_sandbox.py index f616a1d19d8e..250ad3f42e5f 100644 --- a/products/tasks/backend/logic/services/docker_sandbox.py +++ b/products/tasks/backend/logic/services/docker_sandbox.py @@ -774,6 +774,7 @@ def _build_agent_server_command( reasoning_effort: str | None = None, initial_permission_mode: str | None = None, mcp_servers_arg: str = "", + relay_mcp_servers_arg: str = "", allowed_domains: list[str] | None = None, event_ingest_token: str | None = None, event_ingest_url: str | None = None, @@ -814,7 +815,8 @@ def _build_agent_server_command( f"env {unset_flags}BASH_ENV={shlex.quote(BASH_ENV_SCRIPT)} " f"{env_prefix}./node_modules/.bin/agent-server --port {AGENT_SERVER_PORT}{repo_flag} " f"--taskId {shlex.quote(task_id)} --runId {shlex.quote(run_id)} --mode {shlex.quote(mode)}" - f"{create_pr_flag}{auto_publish_flag}{branch_flag}{mcp_servers_arg}{domains_flag}{repo_ready_flag}" + f"{create_pr_flag}{auto_publish_flag}{branch_flag}{mcp_servers_arg}{relay_mcp_servers_arg}" + f"{domains_flag}{repo_ready_flag}" ) # agentsh injects HTTP_PROXY pointing at a per-session egress proxy port; undici @@ -863,6 +865,7 @@ def start_agent_server( reasoning_effort: str | None = None, initial_permission_mode: str | None = None, mcp_configs: list[McpServerConfig] | None = None, + relayed_mcp_servers: list[str] | None = None, allowed_domains: list[str] | None = None, event_ingest_token: str | None = None, event_ingest_url: str | None = None, @@ -901,6 +904,10 @@ def start_agent_server( mcp_json = json.dumps([c.to_dict() for c in mcp_configs]) mcp_servers_arg = f" --mcpServers {shlex.quote(mcp_json)}" + relay_mcp_servers_arg = "" + if relayed_mcp_servers: + relay_mcp_servers_arg = f" --relayMcpServers {shlex.quote(json.dumps(relayed_mcp_servers))}" + if auto_publish and not self.agent_server_supports_auto_publish(): logger.warning(f"Installed agent-server in sandbox {self.id} predates --autoPublish; starting review-first") auto_publish = False @@ -920,6 +927,7 @@ def start_agent_server( reasoning_effort, initial_permission_mode, mcp_servers_arg, + relay_mcp_servers_arg, allowed_domains=allowed_domains, event_ingest_token=event_ingest_token, event_ingest_url=event_ingest_url, @@ -969,6 +977,7 @@ def start_agent_server( reasoning_effort=reasoning_effort, initial_permission_mode=initial_permission_mode, mcp_servers_arg=mcp_servers_arg, + relay_mcp_servers_arg=relay_mcp_servers_arg, allowed_domains=allowed_domains, event_ingest_token=event_ingest_token, event_ingest_url=event_ingest_url, diff --git a/products/tasks/backend/logic/services/modal_sandbox.py b/products/tasks/backend/logic/services/modal_sandbox.py index 5fee2937dc14..622b81307590 100644 --- a/products/tasks/backend/logic/services/modal_sandbox.py +++ b/products/tasks/backend/logic/services/modal_sandbox.py @@ -891,6 +891,7 @@ def _build_agent_server_command( reasoning_effort: str | None = None, initial_permission_mode: str | None = None, mcp_servers_arg: str = "", + relay_mcp_servers_arg: str = "", allowed_domains: list[str] | None = None, event_ingest_token: str | None = None, event_ingest_url: str | None = None, @@ -927,7 +928,8 @@ def _build_agent_server_command( f"env {unset_flags}BASH_ENV={shlex.quote(BASH_ENV_SCRIPT)} " f"{env_prefix}./node_modules/.bin/agent-server --port {AGENT_SERVER_PORT}{repo_flag} " f"--taskId {shlex.quote(task_id)} --runId {shlex.quote(run_id)} --mode {shlex.quote(mode)}" - f"{create_pr_flag}{auto_publish_flag}{branch_flag}{mcp_servers_arg}{domains_flag}{repo_ready_flag}" + f"{create_pr_flag}{auto_publish_flag}{branch_flag}{mcp_servers_arg}{relay_mcp_servers_arg}" + f"{domains_flag}{repo_ready_flag}" ) if repo_ready_file: @@ -1009,6 +1011,7 @@ def start_agent_server( reasoning_effort: str | None = None, initial_permission_mode: str | None = None, mcp_configs: list[McpServerConfig] | None = None, + relayed_mcp_servers: list[str] | None = None, allowed_domains: list[str] | None = None, event_ingest_token: str | None = None, event_ingest_url: str | None = None, @@ -1046,6 +1049,10 @@ def start_agent_server( mcp_json = json.dumps([c.to_dict() for c in mcp_configs]) mcp_servers_arg = f" --mcpServers {shlex.quote(mcp_json)}" + relay_mcp_servers_arg = "" + if relayed_mcp_servers: + relay_mcp_servers_arg = f" --relayMcpServers {shlex.quote(json.dumps(relayed_mcp_servers))}" + if auto_publish and not self.agent_server_supports_auto_publish(): logger.warning(f"Installed agent-server in sandbox {self.id} predates --autoPublish; starting review-first") auto_publish = False @@ -1065,6 +1072,7 @@ def start_agent_server( reasoning_effort, initial_permission_mode, mcp_servers_arg, + relay_mcp_servers_arg, allowed_domains=allowed_domains, event_ingest_token=event_ingest_token, event_ingest_url=event_ingest_url, diff --git a/products/tasks/backend/logic/services/sandbox.py b/products/tasks/backend/logic/services/sandbox.py index 8a647577432e..c0ab2717d28a 100644 --- a/products/tasks/backend/logic/services/sandbox.py +++ b/products/tasks/backend/logic/services/sandbox.py @@ -314,6 +314,7 @@ def start_agent_server( reasoning_effort: str | None = None, initial_permission_mode: str | None = None, mcp_configs: list[McpServerConfig] | None = None, + relayed_mcp_servers: list[str] | None = None, allowed_domains: list[str] | None = None, event_ingest_token: str | None = None, event_ingest_url: str | None = None, diff --git a/products/tasks/backend/migrations/0059_taskrun_imported_mcp_servers.py b/products/tasks/backend/migrations/0059_taskrun_imported_mcp_servers.py new file mode 100644 index 000000000000..c56d5265f27a --- /dev/null +++ b/products/tasks/backend/migrations/0059_taskrun_imported_mcp_servers.py @@ -0,0 +1,22 @@ +from django.db import migrations + +import posthog.helpers.encrypted_fields + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0058_taskthreadmessage_agent_fields"), + ] + + operations = [ + migrations.AddField( + model_name="taskrun", + name="imported_mcp_servers", + field=posthog.helpers.encrypted_fields.EncryptedJSONStringField( + blank=True, + default=None, + help_text="Client-imported MCP server configs (type/name/url/headers) to make available in the sandbox", + null=True, + ), + ), + ] diff --git a/products/tasks/backend/migrations/0060_taskrun_relayed_mcp_servers.py b/products/tasks/backend/migrations/0060_taskrun_relayed_mcp_servers.py new file mode 100644 index 000000000000..aee7232a1d8b --- /dev/null +++ b/products/tasks/backend/migrations/0060_taskrun_relayed_mcp_servers.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tasks", "0059_taskrun_imported_mcp_servers"), + ] + + operations = [ + migrations.AddField( + model_name="taskrun", + name="relayed_mcp_servers", + field=models.JSONField( + blank=True, + default=None, + help_text="Names of desktop-only MCP servers the creating client relays into this run (docs/cloud-mcp-relay.md). Names only — configuration never crosses the wire.", + null=True, + ), + ), + ] diff --git a/products/tasks/backend/migrations/max_migration.txt b/products/tasks/backend/migrations/max_migration.txt index 5405417731ff..b987c7228e64 100644 --- a/products/tasks/backend/migrations/max_migration.txt +++ b/products/tasks/backend/migrations/max_migration.txt @@ -1 +1 @@ -0058_taskthreadmessage_agent_fields +0060_taskrun_relayed_mcp_servers diff --git a/products/tasks/backend/models.py b/products/tasks/backend/models.py index e1aa528a4677..6fe1f20db6c4 100644 --- a/products/tasks/backend/models.py +++ b/products/tasks/backend/models.py @@ -1103,6 +1103,23 @@ class Environment(models.TextChoices): help_text="Run state data for resuming or tracking execution state", ) + # Local url-based MCP servers imported from the creating client (PostHog Code), + # merged into the sandbox agent server's --mcpServers at spawn. Encrypted because + # header values carry credentials; never exposed through API responses. + imported_mcp_servers = EncryptedJSONStringField( + blank=True, + null=True, + default=None, + help_text="Client-imported MCP server configs (type/name/url/headers) to make available in the sandbox", + ) + + relayed_mcp_servers = models.JSONField( + blank=True, + null=True, + default=None, + help_text="Names of desktop-only MCP servers the creating client relays into this run (docs/cloud-mcp-relay.md). Names only — configuration never crosses the wire.", + ) + created_at = models.DateTimeField(default=django_timezone.now) updated_at = models.DateTimeField(auto_now=True) completed_at = models.DateTimeField(null=True, blank=True) diff --git a/products/tasks/backend/presentation/serializers.py b/products/tasks/backend/presentation/serializers.py index 16e73a92884a..09c7276191a7 100644 --- a/products/tasks/backend/presentation/serializers.py +++ b/products/tasks/backend/presentation/serializers.py @@ -19,6 +19,7 @@ from posthog.event_usage import groups from posthog.models.integration import Integration from posthog.models.user_integration import UserIntegration +from posthog.security.url_validation import is_url_allowed from products.tasks.backend.facade import api as tasks_facade from products.tasks.backend.facade.contracts import ( @@ -1502,7 +1503,135 @@ class StreamReadTokenResponseSerializer(serializers.Serializer): ) -class TaskRunCreateRequestSerializer(serializers.Serializer): +MAX_IMPORTED_MCP_SERVERS = 20 +MAX_IMPORTED_MCP_SERVER_NAME_LENGTH = 64 +MAX_IMPORTED_MCP_HEADER_VALUE_LENGTH = 4096 +MAX_IMPORTED_MCP_SERVERS_BYTES = 32768 +# Names already taken by servers the sandbox always gets; imported entries must not shadow them. +RESERVED_IMPORTED_MCP_SERVER_NAMES = {"posthog"} + + +def _validate_unique_unreserved_mcp_names(value: list[dict]) -> None: + """Reject reserved names and case-insensitive duplicates within an MCP-server list. + + Shared by the imported and relayed validators; the per-field caps (count, payload size) stay + in each caller since they differ. + """ + seen: set[str] = set() + for server in value: + name = server["name"] + name_key = name.lower() + if name_key in RESERVED_IMPORTED_MCP_SERVER_NAMES: + raise serializers.ValidationError(f"'{name}' is a reserved MCP server name.") + if name_key in seen: + raise serializers.ValidationError(f"Duplicate MCP server name: '{name}'.") + seen.add(name_key) + + +class ImportedMcpServerHeaderSerializer(serializers.Serializer): + name = serializers.CharField(max_length=256) + value = serializers.CharField( + max_length=MAX_IMPORTED_MCP_HEADER_VALUE_LENGTH, allow_blank=True, trim_whitespace=False + ) + + +class ImportedMcpServerSerializer(serializers.Serializer): + """One client-imported MCP server, in the agent server's --mcpServers entry shape.""" + + type = serializers.ChoiceField(choices=["http", "sse"]) + name = serializers.CharField(max_length=MAX_IMPORTED_MCP_SERVER_NAME_LENGTH) + url = serializers.URLField(max_length=2048) + headers = ImportedMcpServerHeaderSerializer(many=True, required=False, default=list) + + def validate_url(self, value: str) -> str: + # The client classifies public vs private hosts for UX, but that is not a + # security boundary: the sandbox egresses from PostHog infrastructure, so a + # private URL here is a user-controlled SSRF vector. Re-check server-side. + allowed, reason = is_url_allowed(value) + if not allowed: + raise serializers.ValidationError(reason or "URL is not allowed.") + return value + + +class ImportedMcpServersFieldMixin(serializers.Serializer): + """Adds the write-only imported_mcp_servers field shared by both run-creation serializers.""" + + imported_mcp_servers = ImportedMcpServerSerializer( + many=True, + required=False, + allow_null=True, + default=None, + write_only=True, + help_text=( + "Local url-based MCP servers from the creating client (PostHog Code) to make " + "available inside the cloud sandbox. Header values are treated as credentials: " + "stored encrypted and never returned by the API." + ), + ) + + def validate_imported_mcp_servers(self, value): + if not value: + return None + if len(value) > MAX_IMPORTED_MCP_SERVERS: + raise serializers.ValidationError(f"At most {MAX_IMPORTED_MCP_SERVERS} imported MCP servers are allowed.") + _validate_unique_unreserved_mcp_names(value) + if len(json.dumps(value)) > MAX_IMPORTED_MCP_SERVERS_BYTES: + raise serializers.ValidationError("Imported MCP servers payload is too large.") + return value + + +MAX_RELAYED_MCP_SERVERS = 20 + + +class RelayedMcpServerSerializer(serializers.Serializer): + """One desktop-only MCP server relayed into the run — a name only, never configuration.""" + + name = serializers.CharField(max_length=MAX_IMPORTED_MCP_SERVER_NAME_LENGTH) + + +class RelayedMcpServersFieldMixin(serializers.Serializer): + """Adds the write-only relayed_mcp_servers field shared by both run-creation serializers.""" + + relayed_mcp_servers = RelayedMcpServerSerializer( + many=True, + required=False, + allow_null=True, + default=None, + write_only=True, + help_text=( + "Names of desktop-only MCP servers the creating client (PostHog Code) relays into the " + "cloud sandbox over the durable event/command channel. Names only — the server " + "configuration (command, env, URL, headers) never crosses the wire." + ), + ) + + def validate_relayed_mcp_servers(self, value): + if not value: + return None + if len(value) > MAX_RELAYED_MCP_SERVERS: + raise serializers.ValidationError(f"At most {MAX_RELAYED_MCP_SERVERS} relayed MCP servers are allowed.") + _validate_unique_unreserved_mcp_names(value) + return value + + +def get_relayed_imported_mcp_name_collision_error(attrs: dict) -> str | None: + """Relayed and imported MCP server names must be disjoint (case-insensitively). + + Both lists feed the same sandbox `mcpServers` namespace, so a shared name would make one + silently shadow the other. Cross-field, so it runs in each creation serializer's ``validate``. + """ + relayed = attrs.get("relayed_mcp_servers") or [] + imported = attrs.get("imported_mcp_servers") or [] + if not relayed or not imported: + return None + imported_names = {server["name"].lower() for server in imported} + for server in relayed: + if server["name"].lower() in imported_names: + return f"Relayed MCP server name '{server['name']}' collides with an imported MCP server name." + return None + + +class TaskRunCreateRequestSerializer(ImportedMcpServersFieldMixin, RelayedMcpServersFieldMixin, serializers.Serializer): """Request body for creating a new task run""" PR_AUTHORSHIP_MODE_CHOICES = [mode.value for mode in PrAuthorshipMode] @@ -1629,6 +1758,8 @@ class TaskRunCreateRequestSerializer(serializers.Serializer): def validate(self, attrs): errors: dict[str, str] = {} + if collision_error := get_relayed_imported_mcp_name_collision_error(attrs): + errors["relayed_mcp_servers"] = collision_error initial_permission_mode = attrs.get("initial_permission_mode") runtime_adapter = attrs.get("runtime_adapter") if initial_permission_mode is not None: @@ -1689,7 +1820,9 @@ def validate(self, attrs): return attrs -class TaskRunBootstrapCreateRequestSerializer(serializers.Serializer): +class TaskRunBootstrapCreateRequestSerializer( + ImportedMcpServersFieldMixin, RelayedMcpServersFieldMixin, serializers.Serializer +): """Request body for creating a task run without starting execution yet.""" PR_AUTHORSHIP_MODE_CHOICES = [mode.value for mode in PrAuthorshipMode] @@ -1808,6 +1941,8 @@ class TaskRunBootstrapCreateRequestSerializer(serializers.Serializer): def validate(self, attrs): errors: dict[str, str] = {} + if collision_error := get_relayed_imported_mcp_name_collision_error(attrs): + errors["relayed_mcp_servers"] = collision_error initial_permission_mode = attrs.get("initial_permission_mode") runtime_adapter = attrs.get("runtime_adapter") if initial_permission_mode is not None: @@ -2087,8 +2222,14 @@ class TaskRunCommandRequestSerializer(serializers.Serializer): "close", "permission_response", "set_config_option", + "mcp_response", ] + # Cap on the serialized mcp_response params (docs/cloud-mcp-relay.md): the relayed JSON-RPC + # response payload plus envelope must fit in 300 KB. Params are forwarded to the sandbox + # verbatim and never persisted or captured — they carry data from the user's private systems. + MAX_MCP_RESPONSE_PARAMS_BYTES = 300_000 + jsonrpc = serializers.ChoiceField( choices=["2.0"], help_text="JSON-RPC version, must be '2.0'", @@ -2156,6 +2297,26 @@ def validate(self, attrs): elif method == "set_config_option": self._require_nonempty_string(params, "configId") self._require_nonempty_string(params, "value") + elif method == "mcp_response": + self._require_nonempty_string(params, "requestId") + self._require_nonempty_string(params, "server") + payload = params.get("payload") + error = params.get("error") + if (payload is None) == (error is None): + raise serializers.ValidationError({"params": "mcp_response requires exactly one of payload or error"}) + if payload is not None and not isinstance(payload, dict): + raise serializers.ValidationError({"params": "payload must be an object"}) + if error is not None and ( + not isinstance(error, dict) + or isinstance(error.get("code"), bool) + or not isinstance(error.get("code"), int) + or not isinstance(error.get("message"), str) + ): + raise serializers.ValidationError( + {"params": "error must be an object with an integer code and a string message"} + ) + if len(json.dumps(params)) > self.MAX_MCP_RESPONSE_PARAMS_BYTES: + raise serializers.ValidationError({"params": "mcp_response params exceed the 300 KB limit"}) return attrs diff --git a/products/tasks/backend/presentation/views/api.py b/products/tasks/backend/presentation/views/api.py index 79c1d2fab414..9bb08d059d9e 100644 --- a/products/tasks/backend/presentation/views/api.py +++ b/products/tasks/backend/presentation/views/api.py @@ -1476,7 +1476,7 @@ def stream_token(self, request, pk=None, **kwargs): summary="Send command to task run", description="Queue user_message JSON-RPC commands through the task workflow and forward sandbox control " "commands to the agent server. Supports user_message, cancel, close, permission_response, " - "and set_config_option commands.", + "set_config_option, and mcp_response commands.", strict_request_validation=True, ) @action( diff --git a/products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py b/products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py index b770a7d03207..afe1a4f99b03 100644 --- a/products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py +++ b/products/tasks/backend/temporal/process_task/activities/send_followup_to_sandbox.py @@ -27,6 +27,7 @@ from products.tasks.backend.temporal.oauth import create_oauth_access_token_for_run from products.tasks.backend.temporal.process_task.utils import ( get_actor_distinct_id, + get_imported_mcp_server_configs, get_sandbox_ph_mcp_configs, get_task_run_credential_user, get_user_mcp_server_configs, @@ -259,6 +260,12 @@ def _refresh_sandbox_mcp( if user_mcp_configs: mcp_configs = mcp_configs + user_mcp_configs + # refresh_session replaces the session's server list wholesale, so the + # run's imported servers must ride along or they vanish mid-run. + imported_mcp_configs = get_imported_mcp_server_configs(task_run, {config.name for config in mcp_configs}) + if imported_mcp_configs: + mcp_configs = mcp_configs + imported_mcp_configs + if not mcp_configs: logger.info("refresh_mcp_skipped_no_configs", run_id=run_id) return diff --git a/products/tasks/backend/temporal/process_task/activities/start_agent_server.py b/products/tasks/backend/temporal/process_task/activities/start_agent_server.py index 19c6581b8c71..6cc63b2b83b7 100644 --- a/products/tasks/backend/temporal/process_task/activities/start_agent_server.py +++ b/products/tasks/backend/temporal/process_task/activities/start_agent_server.py @@ -25,6 +25,8 @@ from products.tasks.backend.temporal.process_task.utils import ( McpServerConfig, format_allowed_domains_for_log, + get_imported_mcp_server_configs, + get_relayed_mcp_server_names, get_sandbox_ph_mcp_configs, get_task_run_credential_user, get_user_mcp_server_configs, @@ -164,6 +166,7 @@ class StartAgentServerOutput: @dataclass class _LaunchParams: mcp_configs: list[McpServerConfig] + relayed_mcp_servers: list[str] agentsh_domains: list[str] | None protected_base_branch: str | None event_ingest_token: str | None @@ -206,9 +209,16 @@ def _prepare_launch(ctx: TaskProcessingContext, scopes: PosthogMcpScopes) -> _La # Django ASGI short-circuit. Only meaningful once sequenced ingest is enabled. Unset means # the agent falls back to POSTHOG_API_URL (Django). event_ingest_url: str | None = settings.TASKS_AGENT_PROXY_INGEST_URL if event_stream_ingest_enabled else None + # Fetched once; serves both the ingest token and the imported MCP servers below. + task_run = TaskRun.objects.filter(id=ctx.run_id, task_id=ctx.task_id, team_id=ctx.team_id).first() + if task_run is None: + raise SandboxExecutionError( + "Task run not found for agent server launch", + {"task_id": ctx.task_id, "run_id": ctx.run_id}, + cause=TaskRun.DoesNotExist(f"TaskRun {ctx.run_id} not found"), + ) if event_stream_ingest_enabled: try: - task_run = TaskRun.objects.get(id=ctx.run_id, task_id=ctx.task_id, team_id=ctx.team_id) event_ingest_token = create_sandbox_event_ingest_token(task_run) except Exception as e: raise SandboxExecutionError( @@ -235,6 +245,18 @@ def _prepare_launch(ctx: TaskProcessingContext, scopes: PosthogMcpScopes) -> _La if user_mcp_configs: mcp_configs = mcp_configs + user_mcp_configs + imported_mcp_configs = get_imported_mcp_server_configs(task_run, {config.name for config in mcp_configs}) + if imported_mcp_configs: + mcp_configs = mcp_configs + imported_mcp_configs + + relayed_names = get_relayed_mcp_server_names(task_run, {config.name for config in mcp_configs}) + if relayed_names: + emit_agent_log( + ctx.run_id, + "debug", + f"Resolved {len(relayed_names)} relayed MCP server name(s) for agent server: {', '.join(relayed_names)}", + ) + if mcp_configs: emit_agent_log( ctx.run_id, @@ -275,6 +297,7 @@ def _prepare_launch(ctx: TaskProcessingContext, scopes: PosthogMcpScopes) -> _La return _LaunchParams( mcp_configs=mcp_configs, + relayed_mcp_servers=relayed_names, agentsh_domains=agentsh_domains, protected_base_branch=protected_base_branch, event_ingest_token=event_ingest_token, @@ -307,6 +330,7 @@ def _invoke_start_agent_server( reasoning_effort=ctx.reasoning_effort, initial_permission_mode=ctx.initial_permission_mode, mcp_configs=params.mcp_configs or None, + relayed_mcp_servers=params.relayed_mcp_servers or None, allowed_domains=params.agentsh_domains, event_ingest_token=params.event_ingest_token, event_ingest_url=params.event_ingest_url, diff --git a/products/tasks/backend/temporal/process_task/activities/tests/test_start_agent_server.py b/products/tasks/backend/temporal/process_task/activities/tests/test_start_agent_server.py index bd70bb153c1b..29773c1cff61 100644 --- a/products/tasks/backend/temporal/process_task/activities/tests/test_start_agent_server.py +++ b/products/tasks/backend/temporal/process_task/activities/tests/test_start_agent_server.py @@ -158,9 +158,8 @@ async def test_start_agent_server_uses_captured_sandbox_event_ingest_flag(mocker return_value=[], ) mocker.patch( - "products.tasks.backend.temporal.process_task.activities.start_agent_server.TaskRun.objects.get", - return_value=mocker.Mock(), - ) + "products.tasks.backend.temporal.process_task.activities.start_agent_server.TaskRun.objects.filter", + ).return_value.first.return_value = mocker.Mock(state={}, imported_mcp_servers=None) create_event_ingest_token = mocker.patch( "products.tasks.backend.temporal.process_task.activities.start_agent_server.create_sandbox_event_ingest_token", return_value="event-ingest-token", @@ -182,6 +181,56 @@ async def test_start_agent_server_uses_captured_sandbox_event_ingest_flag(mocker assert sandbox.start_agent_server.call_args.kwargs["event_ingest_token"] == "event-ingest-token" +async def test_start_agent_server_forwards_imported_and_relayed_mcp_servers(mocker) -> None: + context = _context() + sandbox = mocker.Mock() + sandbox.execute.return_value.stdout = "" + sandbox.execute.return_value.stderr = "" + mocker.patch( + "products.tasks.backend.temporal.process_task.activities.start_agent_server.Sandbox.get_by_id", + return_value=sandbox, + ) + mocker.patch("products.tasks.backend.temporal.process_task.activities.start_agent_server.emit_agent_log") + mocker.patch( + "products.tasks.backend.temporal.process_task.activities.start_agent_server.Task.objects.select_related" + ).return_value.get.return_value = mocker.Mock(created_by_id=None) + mocker.patch( + "products.tasks.backend.temporal.process_task.activities.start_agent_server.create_oauth_access_token_for_run", + return_value="oauth-token", + ) + mocker.patch( + "products.tasks.backend.temporal.process_task.activities.start_agent_server.get_sandbox_ph_mcp_configs", + return_value=[], + ) + mocker.patch( + "products.tasks.backend.temporal.process_task.activities.start_agent_server.TaskRun.objects.filter", + ).return_value.first.return_value = mocker.Mock( + state={}, + imported_mcp_servers=[ + {"type": "http", "name": "linear", "url": "https://mcp.linear.app", "headers": []}, + ], + relayed_mcp_servers=[{"name": "slack"}], + ) + mocker.patch( + "products.tasks.backend.temporal.process_task.activities.start_agent_server.create_sandbox_event_ingest_token", + return_value="event-ingest-token", + ) + + await start_agent_server( + StartAgentServerInput( + context=context, + sandbox_id="sandbox-id", + sandbox_url="https://sandbox.example", + sandbox_connect_token="connect-token", + ) + ) + + sandbox.start_agent_server.assert_called_once() + kwargs = sandbox.start_agent_server.call_args.kwargs + assert [config.name for config in kwargs["mcp_configs"]] == ["linear"] + assert kwargs["relayed_mcp_servers"] == ["slack"] + + async def test_start_agent_server_passes_initial_permission_mode(mocker) -> None: context = _context(state={"initial_permission_mode": "plan"}) sandbox = mocker.Mock() @@ -203,6 +252,9 @@ async def test_start_agent_server_passes_initial_permission_mode(mocker) -> None "products.tasks.backend.temporal.process_task.activities.start_agent_server.get_sandbox_ph_mcp_configs", return_value=[], ) + mocker.patch( + "products.tasks.backend.temporal.process_task.activities.start_agent_server.TaskRun.objects.filter" + ).return_value.first.return_value = mocker.Mock(state={}, imported_mcp_servers=None) await start_agent_server( StartAgentServerInput( diff --git a/products/tasks/backend/temporal/process_task/tests/test_send_followup_to_sandbox.py b/products/tasks/backend/temporal/process_task/tests/test_send_followup_to_sandbox.py index 5ff23e9eaf7c..251476b0229e 100644 --- a/products/tasks/backend/temporal/process_task/tests/test_send_followup_to_sandbox.py +++ b/products/tasks/backend/temporal/process_task/tests/test_send_followup_to_sandbox.py @@ -56,6 +56,7 @@ def _make_task_run_mock(team_id: int = 7, created_by_id: int | None = 42, state: # MagicMock auto-attributes would otherwise return further MagicMock objects # and leak into kwargs passed to `get_sandbox_ph_mcp_configs`. task_run.state = state + task_run.imported_mcp_servers = None return task_run @@ -92,6 +93,49 @@ def test_success_path_single_call(self, mock_oauth, mock_ph_configs, mock_user_c mcp_servers = mock_send_refresh.call_args.args[1] assert mcp_servers == [_make_mcp_config(token="fresh-token").to_dict()] + @patch("products.tasks.backend.temporal.process_task.activities.send_followup_to_sandbox.send_refresh_session") + @patch( + "products.tasks.backend.temporal.process_task.activities.send_followup_to_sandbox.get_user_mcp_server_configs" + ) + @patch( + "products.tasks.backend.temporal.process_task.activities.send_followup_to_sandbox.get_sandbox_ph_mcp_configs" + ) + @patch( + "products.tasks.backend.temporal.process_task.activities.send_followup_to_sandbox.create_oauth_access_token_for_run" + ) + def test_refresh_keeps_imported_mcp_servers( + self, mock_oauth, mock_ph_configs, mock_user_configs, mock_send_refresh + ): + """refresh_session replaces the session's server list wholesale; without + this, the run's client-imported servers vanish at the first token refresh.""" + mock_oauth.return_value = "fresh-token" + mock_ph_configs.return_value = [_make_mcp_config(token="fresh-token")] + mock_user_configs.return_value = [] + mock_send_refresh.return_value = CommandResult(success=True, status_code=200) + + task_run = _make_task_run_mock() + task_run.imported_mcp_servers = [ + { + "type": "http", + "name": "grafana", + "url": "https://mcp.grafana.example.com/mcp", + "headers": [{"name": "Authorization", "value": "Bearer x"}], + }, + # collides with the PostHog MCP config: existing servers win + {"type": "http", "name": "posthog", "url": "https://shadow.example.com/mcp", "headers": []}, + ] + + _refresh_sandbox_mcp(task_run, "read_only", auth_token="jwt") + + mcp_servers = mock_send_refresh.call_args.args[1] + assert [server["name"] for server in mcp_servers] == ["posthog", "grafana"] + assert mcp_servers[1] == { + "type": "http", + "name": "grafana", + "url": "https://mcp.grafana.example.com/mcp", + "headers": [{"name": "Authorization", "value": "Bearer x"}], + } + @patch("products.tasks.backend.temporal.process_task.activities.send_followup_to_sandbox.time.sleep") @patch("products.tasks.backend.temporal.process_task.activities.send_followup_to_sandbox.send_refresh_session") @patch( diff --git a/products/tasks/backend/temporal/process_task/tests/test_utils.py b/products/tasks/backend/temporal/process_task/tests/test_utils.py index be7772f28000..1b1bec005e44 100644 --- a/products/tasks/backend/temporal/process_task/tests/test_utils.py +++ b/products/tasks/backend/temporal/process_task/tests/test_utils.py @@ -15,8 +15,11 @@ GitHubCredentialSource, McpServerConfig, RunState, + build_imported_mcp_server_configs, get_git_identity_env_vars, get_github_credential_source, + get_imported_mcp_server_configs, + get_relayed_mcp_server_names, get_sandbox_github_token, get_sandbox_ph_mcp_configs, get_task_run_actor_user, @@ -888,3 +891,140 @@ def test_bot_authorship_prefers_team_integration_over_user_install_token(self) - ) assert result == "ghs_team" + + +class TestBuildImportedMcpServerConfigs(TestCase): + def test_returns_empty_for_none_empty_or_non_list(self): + assert build_imported_mcp_server_configs(None, set()) == [] + assert build_imported_mcp_server_configs([], {"posthog"}) == [] + # The encrypted column is schemaless at read time; drift must not break launches. + assert build_imported_mcp_server_configs("junk", set()) == [] + + def test_builds_configs_dropping_collisions_and_malformed_entries(self): + configs = build_imported_mcp_server_configs( + [ + { + "type": "http", + "name": "grafana", + "url": "https://mcp.grafana.example.com/mcp", + "headers": [{"name": "Authorization", "value": "Bearer x"}], + }, + # collides with an already-resolved server: existing servers win + {"type": "sse", "name": "posthog", "url": "https://shadow.example.com/mcp"}, + # duplicate of an earlier imported entry: first one wins + {"type": "http", "name": "grafana", "url": "https://dup.example.com/mcp"}, + # missing type falls back to http; malformed headers are dropped + {"name": "docs", "url": "https://docs.example.com/mcp", "headers": [{"name": "X"}, "junk"]}, + # unusable entries are skipped + {"type": "http", "url": "https://no-name.example.com"}, + {"type": "http", "name": "no-url"}, + "garbage", + ], + existing_names={"posthog"}, + ) + + assert configs == [ + McpServerConfig( + type="http", + name="grafana", + url="https://mcp.grafana.example.com/mcp", + headers=[{"name": "Authorization", "value": "Bearer x"}], + ), + McpServerConfig(type="http", name="docs", url="https://docs.example.com/mcp", headers=[]), + ] + + def test_collision_detection_is_case_insensitive(self): + # Matches the serializer's case-insensitive validation: a name differing only in + # case from an existing server (or an earlier imported entry) is dropped, and the + # surviving entry keeps its original casing. + configs = build_imported_mcp_server_configs( + [ + {"type": "http", "name": "Grafana", "url": "https://a.example.com/mcp"}, + # collides case-insensitively with an already-resolved server + {"type": "http", "name": "POSTHOG", "url": "https://b.example.com/mcp"}, + # collides case-insensitively with the earlier imported "Grafana" + {"type": "http", "name": "grafana", "url": "https://c.example.com/mcp"}, + ], + existing_names={"posthog"}, + ) + + assert [c.name for c in configs] == ["Grafana"] + + +class TestGetImportedMcpServerConfigs(TestCase): + """The claude-only adapter gate — codex-acp hard-fails a session on any unreachable MCP server + and the sandbox does no reachability pruning, so imported servers must not enter a codex run.""" + + _SERVERS = [{"type": "http", "name": "grafana", "url": "https://mcp.grafana.example.com/mcp"}] + + @staticmethod + def _task_run(runtime_adapter): + state = {} if runtime_adapter is None else {"runtime_adapter": runtime_adapter} + return MagicMock(imported_mcp_servers=TestGetImportedMcpServerConfigs._SERVERS, state=state) + + @parameterized.expand([("claude", "claude"), ("unset", None)]) + def test_resolves_configs_for_claude_or_unset_adapter(self, _name, adapter): + configs = get_imported_mcp_server_configs(self._task_run(adapter), set()) + assert [c.name for c in configs] == ["grafana"] + + def test_returns_empty_for_codex_adapter(self): + assert get_imported_mcp_server_configs(self._task_run("codex"), set()) == [] + + +class TestGetRelayedMcpServerNames(TestCase): + @staticmethod + def _task_run(relayed_mcp_servers): + return MagicMock(relayed_mcp_servers=relayed_mcp_servers) + + def test_returns_valid_names(self): + task_run = self._task_run([{"name": "playwright"}, {"name": "internal-cli"}]) + + assert get_relayed_mcp_server_names(task_run, {"posthog"}) == ["playwright", "internal-cli"] + + @parameterized.expand( + [ + ("none", None), + ("empty_list", []), + # The stored column is schemaless at read time; drift must not break launches. + ("non_list", "junk"), + ] + ) + def test_returns_empty_for_non_list_drift(self, _name, stored): + assert get_relayed_mcp_server_names(self._task_run(stored), set()) == [] + + def test_skips_malformed_entries(self): + task_run = self._task_run( + [ + {"name": "playwright"}, + "garbage", + {"name": ""}, + {"name": 42}, + {"url": "https://no-name.example.com"}, + # duplicate of an earlier relayed entry: first one wins + {"name": "playwright"}, + ] + ) + + assert get_relayed_mcp_server_names(task_run, set()) == ["playwright"] + + def test_drops_names_colliding_with_resolved_configs(self): + task_run = self._task_run([{"name": "posthog"}, {"name": "grafana"}, {"name": "playwright"}]) + + # Names taken by already-resolved MCP configs (PostHog MCP, MCP Store, imported) win. + assert get_relayed_mcp_server_names(task_run, {"posthog", "grafana"}) == ["playwright"] + + def test_collision_detection_is_case_insensitive(self): + # Matches the serializer's case-insensitive validation: names differing only in case + # from an existing server or an earlier relayed entry are dropped, original casing kept. + task_run = self._task_run( + [ + {"name": "Playwright"}, + # collides case-insensitively with an already-resolved server + {"name": "GRAFANA"}, + {"name": "internal-cli"}, + # collides case-insensitively with the earlier relayed "Playwright" + {"name": "playwright"}, + ] + ) + + assert get_relayed_mcp_server_names(task_run, {"grafana"}) == ["Playwright", "internal-cli"] diff --git a/products/tasks/backend/temporal/process_task/utils.py b/products/tasks/backend/temporal/process_task/utils.py index 61e95211715f..a63425462628 100644 --- a/products/tasks/backend/temporal/process_task/utils.py +++ b/products/tasks/backend/temporal/process_task/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from collections.abc import Iterable from dataclasses import dataclass, field from enum import StrEnum from typing import TYPE_CHECKING, Any, Optional @@ -38,7 +39,9 @@ from products.tasks.backend.redis import get_tasks_cache if TYPE_CHECKING: - from products.tasks.backend.models import SandboxSnapshot, Task + from posthog.models.user import User + + from products.tasks.backend.models import SandboxSnapshot, Task, TaskRun logger = logging.getLogger(__name__) @@ -453,6 +456,101 @@ def get_user_mcp_server_configs( return configs +def build_imported_mcp_server_configs( + imported_servers: Any, + existing_names: Iterable[str], +) -> list[McpServerConfig]: + """Sandbox configs for client-imported MCP servers (TaskRun.imported_mcp_servers). + + Entries whose name collides with an already-resolved server (the PostHog MCP + or an MCP Store installation) are dropped — existing servers win. Malformed + entries are skipped rather than failing the launch; the shape was validated + at run creation, so this only guards against drift in stored data. + """ + if not isinstance(imported_servers, list): + return [] + # Case-insensitive dedup to match the serializer's validation (reserved names + # and within-list duplicates are compared lowercased); existing servers win. + taken = {n.lower() for n in existing_names} + configs: list[McpServerConfig] = [] + for server in imported_servers: + if not isinstance(server, dict): + continue + name = server.get("name") + url = server.get("url") + if not isinstance(name, str) or not name or not isinstance(url, str) or not url: + continue + name_lower = name.lower() + if name_lower in taken: + continue + taken.add(name_lower) + server_type = server.get("type") + headers = [ + {"name": header["name"], "value": header["value"]} + for header in server.get("headers") or [] + if isinstance(header, dict) and isinstance(header.get("name"), str) and isinstance(header.get("value"), str) + ] + configs.append( + McpServerConfig( + type=server_type if server_type in ("http", "sse") else "http", + name=name, + url=url, + headers=headers, + ) + ) + return configs + + +def get_relayed_mcp_server_names(task_run: TaskRun, existing_names: Iterable[str]) -> list[str]: + """Names of desktop-only MCP servers relayed into the run (TaskRun.relayed_mcp_servers). + + Names whose entry collides with an already-resolved MCP config (the PostHog MCP, an MCP Store + installation, or an imported server) are dropped — existing servers win. Malformed entries are + skipped rather than failing the launch; the shape was validated at run creation, so this only + guards against drift in stored data. + + Not adapter-gated (unlike imported servers): the relay endpoints are loopback HTTP servers in + the sandbox that always accept the connection and return an HTTP response (a relayed result, or + a 503 on a genuine mid-run desktop disconnect). Codex's reachability probe treats any HTTP + response — including a 503 — as reachable and only prunes on a transport failure, so a relay + endpoint never gets pruned from a codex session the way an unreachable remote URL would. + """ + relayed_servers = task_run.relayed_mcp_servers + if not isinstance(relayed_servers, list): + return [] + # Case-insensitive dedup to match the serializer's validation (reserved names + # and within-list duplicates are compared lowercased); existing servers win. + taken = {n.lower() for n in existing_names} + names: list[str] = [] + for server in relayed_servers: + if not isinstance(server, dict): + continue + name = server.get("name") + if not isinstance(name, str) or not name: + continue + name_lower = name.lower() + if name_lower in taken: + continue + taken.add(name_lower) + names.append(name) + return names + + +def get_imported_mcp_server_configs(task_run: TaskRun, existing_names: Iterable[str]) -> list[McpServerConfig]: + """Sandbox configs for the run's client-imported MCP servers. + + Claude-only for now: codex-acp hard-fails the session when any configured + MCP server is unreachable and the sandbox does no reachability pruning (an + unset adapter defaults to claude). Used both at launch and when a mid-run + refresh_session replaces the session's server list — the agent treats that + list as authoritative, so leaving these out would drop them from the run. + """ + runtime_adapter = (task_run.state or {}).get("runtime_adapter") + if runtime_adapter not in (None, RuntimeAdapter.CLAUDE.value): + return [] + return build_imported_mcp_server_configs(task_run.imported_mcp_servers, existing_names) + + def _resolve_mcp_consumer(interaction_origin: str | None) -> str: """Map the task's interaction origin to the `x-posthog-mcp-consumer` value. diff --git a/products/tasks/backend/tests/test_api.py b/products/tasks/backend/tests/test_api.py index 11da5408f6a1..e78f90e7c934 100644 --- a/products/tasks/backend/tests/test_api.py +++ b/products/tasks/backend/tests/test_api.py @@ -1812,6 +1812,226 @@ def test_create_run_endpoint_creates_cloud_run_without_triggering_workflow(self, self.assertEqual(task_run.state["auto_publish"], True) mock_workflow.assert_not_called() + # is_url_allowed resolves DNS for real in CI, and example.com subdomains don't resolve. + @patch("products.tasks.backend.presentation.serializers.is_url_allowed", return_value=(True, None)) + @patch("products.tasks.backend.temporal.client.execute_task_processing_workflow") + def test_create_run_endpoint_persists_imported_mcp_servers_outside_state(self, mock_workflow, _mock_url_allowed): + task = self.create_task() + servers = [ + { + "type": "http", + "name": "grafana", + "url": "https://mcp.grafana.example.com/mcp", + "headers": [{"name": "Authorization", "value": "Bearer abc"}], + }, + {"type": "sse", "name": "docs", "url": "https://docs.example.com/mcp", "headers": []}, + ] + + response = self.client.post( + f"/api/projects/@current/tasks/{task.id}/runs/", + { + "environment": "cloud", + "mode": "interactive", + "runtime_adapter": "claude", + "model": "claude-sonnet-4-5", + "imported_mcp_servers": servers, + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + task_run = TaskRun.objects.get(id=response.json()["id"]) + self.assertEqual(task_run.imported_mcp_servers, servers) + # Header values are credentials: never echoed back, never in the plain state JSON. + self.assertNotIn("imported_mcp_servers", response.json()) + self.assertNotIn("imported_mcp_servers", task_run.state) + mock_workflow.assert_not_called() + + @patch("products.tasks.backend.presentation.serializers.is_url_allowed", return_value=(True, None)) + @patch("products.tasks.backend.temporal.client.execute_task_processing_workflow") + def test_run_endpoint_persists_imported_mcp_servers(self, mock_workflow, _mock_url_allowed): + task = self.create_task() + servers = [{"type": "http", "name": "grafana", "url": "https://mcp.grafana.example.com/mcp", "headers": []}] + + response = self.client.post( + f"/api/projects/@current/tasks/{task.id}/run/", + {"runtime_adapter": "claude", "model": "claude-sonnet-4-5", "imported_mcp_servers": servers}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + task_run = task.runs.latest("created_at") + self.assertEqual(task_run.imported_mcp_servers, servers) + self.assertNotIn("imported_mcp_servers", task_run.state) + self.assertNotIn("imported_mcp_servers", json.dumps(response.json())) + + # FORCE_URL_VALIDATION exercises the real SSRF guard; otherwise is_url_allowed short-circuits in tests. + @override_settings(FORCE_URL_VALIDATION=True) + def test_create_run_endpoint_rejects_private_imported_mcp_server_urls(self): + task = self.create_task() + for url in ("http://192.168.1.4:8000/mcp", "http://10.0.0.5/mcp", "http://localhost:3001/mcp"): + response = self.client.post( + f"/api/projects/@current/tasks/{task.id}/runs/", + { + "environment": "cloud", + "imported_mcp_servers": [{"type": "http", "name": "internal", "url": url, "headers": []}], + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, url) + self.assertIn("imported_mcp_servers", json.dumps(response.json())) + self.assertEqual(task.runs.count(), 0) + + @parameterized.expand( + [ + ( + "reserved_name", + [{"type": "http", "name": "posthog", "url": "https://a.example.com", "headers": []}], + ), + ( + "duplicate_names", + [ + {"type": "http", "name": "dup", "url": "https://a.example.com", "headers": []}, + {"type": "http", "name": "dup", "url": "https://b.example.com", "headers": []}, + ], + ), + ( + "duplicate_names_differing_case", + [ + {"type": "http", "name": "MyServer", "url": "https://a.example.com", "headers": []}, + {"type": "http", "name": "myserver", "url": "https://b.example.com", "headers": []}, + ], + ), + ( + "too_many_servers", + [ + {"type": "http", "name": f"server-{i}", "url": "https://a.example.com", "headers": []} + for i in range(21) + ], + ), + ( + "oversized_header_value", + [ + { + "type": "http", + "name": "big", + "url": "https://a.example.com", + "headers": [{"name": "Authorization", "value": "x" * 5000}], + } + ], + ), + ] + ) + def test_create_run_endpoint_rejects_invalid_imported_mcp_servers(self, _name, servers): + task = self.create_task() + + response = self.client.post( + f"/api/projects/@current/tasks/{task.id}/runs/", + {"environment": "cloud", "imported_mcp_servers": servers}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(task.runs.count(), 0) + + @patch("products.tasks.backend.temporal.client.execute_task_processing_workflow") + def test_create_run_endpoint_persists_relayed_mcp_servers_outside_state(self, mock_workflow): + task = self.create_task() + servers = [{"name": "playwright"}, {"name": "internal-cli"}] + + response = self.client.post( + f"/api/projects/@current/tasks/{task.id}/runs/", + { + "environment": "cloud", + "mode": "interactive", + "runtime_adapter": "claude", + "model": "claude-sonnet-4-5", + "relayed_mcp_servers": servers, + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + task_run = TaskRun.objects.get(id=response.json()["id"]) + self.assertEqual(task_run.relayed_mcp_servers, servers) + # Names only, and still write-only: never echoed back, never in the plain state JSON. + self.assertNotIn("relayed_mcp_servers", response.json()) + self.assertNotIn("relayed_mcp_servers", task_run.state) + mock_workflow.assert_not_called() + + @patch("products.tasks.backend.temporal.client.execute_task_processing_workflow") + def test_run_endpoint_persists_relayed_mcp_servers(self, mock_workflow): + task = self.create_task() + servers = [{"name": "playwright"}] + + response = self.client.post( + f"/api/projects/@current/tasks/{task.id}/run/", + {"runtime_adapter": "claude", "model": "claude-sonnet-4-5", "relayed_mcp_servers": servers}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + task_run = task.runs.latest("created_at") + self.assertEqual(task_run.relayed_mcp_servers, servers) + self.assertNotIn("relayed_mcp_servers", task_run.state) + self.assertNotIn("relayed_mcp_servers", json.dumps(response.json())) + + @parameterized.expand( + [ + ("reserved_name", [{"name": "posthog"}], None), + ("duplicate_names", [{"name": "dup"}, {"name": "dup"}], None), + ("duplicate_names_differing_case", [{"name": "MyServer"}, {"name": "myserver"}], None), + ("too_many_servers", [{"name": f"server-{i}"} for i in range(21)], None), + ( + "collides_with_imported_name_differing_case", + [{"name": "Grafana"}], + [{"type": "http", "name": "grafana", "url": "https://mcp.grafana.example.com/mcp", "headers": []}], + ), + ] + ) + @patch("products.tasks.backend.presentation.serializers.is_url_allowed", return_value=(True, None)) + def test_create_run_endpoint_rejects_invalid_relayed_mcp_servers(self, _name, relayed, imported, _mock_url_allowed): + task = self.create_task() + body: dict = {"environment": "cloud", "relayed_mcp_servers": relayed} + if imported is not None: + body["imported_mcp_servers"] = imported + + response = self.client.post( + f"/api/projects/@current/tasks/{task.id}/runs/", + body, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(task.runs.count(), 0) + + @parameterized.expand( + [ + ("reserved_name", [{"name": "posthog"}], None), + ("duplicate_names_differing_case", [{"name": "MyServer"}, {"name": "myserver"}], None), + ( + "collides_with_imported_name", + [{"name": "grafana"}], + [{"type": "http", "name": "grafana", "url": "https://mcp.grafana.example.com/mcp", "headers": []}], + ), + ] + ) + @patch("products.tasks.backend.presentation.serializers.is_url_allowed", return_value=(True, None)) + def test_run_endpoint_rejects_invalid_relayed_mcp_servers(self, _name, relayed, imported, _mock_url_allowed): + task = self.create_task() + body: dict = {"relayed_mcp_servers": relayed} + if imported is not None: + body["imported_mcp_servers"] = imported + + response = self.client.post( + f"/api/projects/@current/tasks/{task.id}/run/", + body, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(task.runs.count(), 0) + @patch("products.tasks.backend.temporal.client.execute_task_processing_workflow") def test_create_run_endpoint_caches_user_github_token(self, mock_workflow): integration = Integration.objects.create(team=self.team, kind="github", config={"access_token": "token"}) @@ -7737,6 +7957,75 @@ def test_command_proxies_set_config_option(self, mock_post): self.assertEqual(call_kwargs["json"]["params"]["configId"], "mode") self.assertEqual(call_kwargs["json"]["params"]["value"], "plan") + @override_settings(SANDBOX_JWT_PRIVATE_KEY=TEST_RSA_PRIVATE_KEY) + @patch("products.tasks.backend.presentation.views.api.http_requests.post") + def test_command_proxies_mcp_response_with_payload(self, mock_post): + reset_sandbox_jwt_key_cache() + self._mock_agent_response( + mock_post, + {"jsonrpc": "2.0", "id": "req-6", "result": {"acknowledged": True}}, + ) + + task = self.create_task() + run = self._create_run_with_sandbox(task) + + response = self.client.post( + self._command_url(task, run), + { + "jsonrpc": "2.0", + "method": "mcp_response", + "params": { + "requestId": "relay-1", + "server": "playwright", + "payload": {"jsonrpc": "2.0", "id": 1, "result": {"content": []}}, + }, + "id": "req-6", + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + call_kwargs = mock_post.call_args[1] + self.assertEqual(call_kwargs["json"]["method"], "mcp_response") + self.assertEqual(call_kwargs["json"]["params"]["requestId"], "relay-1") + self.assertEqual(call_kwargs["json"]["params"]["server"], "playwright") + # The relayed JSON-RPC response is forwarded to the sandbox verbatim. + self.assertEqual( + call_kwargs["json"]["params"]["payload"], {"jsonrpc": "2.0", "id": 1, "result": {"content": []}} + ) + + @override_settings(SANDBOX_JWT_PRIVATE_KEY=TEST_RSA_PRIVATE_KEY) + @patch("products.tasks.backend.presentation.views.api.http_requests.post") + def test_command_proxies_mcp_response_with_error(self, mock_post): + reset_sandbox_jwt_key_cache() + self._mock_agent_response( + mock_post, + {"jsonrpc": "2.0", "id": "req-7", "result": {"acknowledged": True}}, + ) + + task = self.create_task() + run = self._create_run_with_sandbox(task) + + response = self.client.post( + self._command_url(task, run), + { + "jsonrpc": "2.0", + "method": "mcp_response", + "params": { + "requestId": "relay-2", + "server": "playwright", + "error": {"code": -32001, "message": "server process exited"}, + }, + "id": "req-7", + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + call_kwargs = mock_post.call_args[1] + self.assertEqual(call_kwargs["json"]["method"], "mcp_response") + self.assertEqual(call_kwargs["json"]["params"]["error"], {"code": -32001, "message": "server process exited"}) + def _create_posthog_ai_task(self, created_by: User | None = None): return Task.objects.create( team=self.team, @@ -7935,6 +8224,88 @@ def test_command_fails_without_sandbox_url(self): "set_config_option_empty_params", {"jsonrpc": "2.0", "method": "set_config_option", "params": {}}, ), + ( + "mcp_response_missing_requestId", + { + "jsonrpc": "2.0", + "method": "mcp_response", + "params": {"server": "playwright", "payload": {"result": {}}}, + }, + ), + ( + "mcp_response_missing_server", + { + "jsonrpc": "2.0", + "method": "mcp_response", + "params": {"requestId": "relay-1", "payload": {"result": {}}}, + }, + ), + ( + "mcp_response_missing_payload_and_error", + { + "jsonrpc": "2.0", + "method": "mcp_response", + "params": {"requestId": "relay-1", "server": "playwright"}, + }, + ), + ( + "mcp_response_both_payload_and_error", + { + "jsonrpc": "2.0", + "method": "mcp_response", + "params": { + "requestId": "relay-1", + "server": "playwright", + "payload": {"result": {}}, + "error": {"code": -32001, "message": "boom"}, + }, + }, + ), + ( + "mcp_response_non_object_payload", + { + "jsonrpc": "2.0", + "method": "mcp_response", + "params": {"requestId": "relay-1", "server": "playwright", "payload": "not-an-object"}, + }, + ), + ( + "mcp_response_malformed_error", + { + "jsonrpc": "2.0", + "method": "mcp_response", + "params": { + "requestId": "relay-1", + "server": "playwright", + "error": {"code": "not-an-int", "message": "boom"}, + }, + }, + ), + ( + # bool is an int subclass — the isinstance(code, bool) guard must still reject it. + "mcp_response_bool_error_code", + { + "jsonrpc": "2.0", + "method": "mcp_response", + "params": { + "requestId": "relay-1", + "server": "playwright", + "error": {"code": True, "message": "boom"}, + }, + }, + ), + ( + "mcp_response_oversized_params", + { + "jsonrpc": "2.0", + "method": "mcp_response", + "params": { + "requestId": "relay-1", + "server": "playwright", + "payload": {"result": "x" * 300_001}, + }, + }, + ), ] ) def test_command_rejects_invalid_payloads(self, _name, payload): diff --git a/products/tasks/frontend/generated/api.schemas.ts b/products/tasks/frontend/generated/api.schemas.ts index 576814191853..b45469af8e4c 100644 --- a/products/tasks/frontend/generated/api.schemas.ts +++ b/products/tasks/frontend/generated/api.schemas.ts @@ -881,6 +881,45 @@ export interface TaskPresenceBeaconRequestApi { device_id: string } +/** + * * `http` - http + * * `sse` - sse + */ +export type ImportedMcpServerTypeEnumApi = + (typeof ImportedMcpServerTypeEnumApi)[keyof typeof ImportedMcpServerTypeEnumApi] + +export const ImportedMcpServerTypeEnumApi = { + Http: 'http', + Sse: 'sse', +} as const + +export interface ImportedMcpServerHeaderApi { + /** @maxLength 256 */ + name: string + /** @maxLength 4096 */ + value: string +} + +/** + * One client-imported MCP server, in the agent server's --mcpServers entry shape. + */ +export interface ImportedMcpServerApi { + type: ImportedMcpServerTypeEnumApi + /** @maxLength 64 */ + name: string + /** @maxLength 2048 */ + url: string + headers?: ImportedMcpServerHeaderApi[] +} + +/** + * One desktop-only MCP server relayed into the run — a name only, never configuration. + */ +export interface RelayedMcpServerApi { + /** @maxLength 64 */ + name: string +} + /** * * `interactive` - interactive * * `background` - background @@ -945,6 +984,16 @@ export const InitialPermissionModeEnumApi = { * Request body for creating a new task run */ export interface ClaudeTaskRunCreateSchemaApi { + /** + * Local url-based MCP servers from the creating client (PostHog Code) to make available inside the cloud sandbox. Header values are treated as credentials: stored encrypted and never returned by the API. + * @nullable + */ + imported_mcp_servers?: ImportedMcpServerApi[] | null + /** + * Names of desktop-only MCP servers the creating client (PostHog Code) relays into the cloud sandbox over the durable event/command channel. Names only — the server configuration (command, env, URL, headers) never crosses the wire. + * @nullable + */ + relayed_mcp_servers?: RelayedMcpServerApi[] | null /** Execution mode: 'interactive' for user-connected runs, 'background' for autonomous runs * * * `interactive` - interactive @@ -1046,6 +1095,16 @@ export const CodexTaskRunCreateSchemaInitialPermissionModeEnumApi = { * Request body for creating a new task run */ export interface CodexTaskRunCreateSchemaApi { + /** + * Local url-based MCP servers from the creating client (PostHog Code) to make available inside the cloud sandbox. Header values are treated as credentials: stored encrypted and never returned by the API. + * @nullable + */ + imported_mcp_servers?: ImportedMcpServerApi[] | null + /** + * Names of desktop-only MCP servers the creating client (PostHog Code) relays into the cloud sandbox over the durable event/command channel. Names only — the server configuration (command, env, URL, headers) never crosses the wire. + * @nullable + */ + relayed_mcp_servers?: RelayedMcpServerApi[] | null /** Execution mode: 'interactive' for user-connected runs, 'background' for autonomous runs * * * `interactive` - interactive @@ -1511,6 +1570,16 @@ export const TaskRunBootstrapCreateRequestInitialPermissionModeEnumApi = { * Request body for creating a task run without starting execution yet. */ export interface TaskRunBootstrapCreateRequestApi { + /** + * Local url-based MCP servers from the creating client (PostHog Code) to make available inside the cloud sandbox. Header values are treated as credentials: stored encrypted and never returned by the API. + * @nullable + */ + imported_mcp_servers?: ImportedMcpServerApi[] | null + /** + * Names of desktop-only MCP servers the creating client (PostHog Code) relays into the cloud sandbox over the durable event/command channel. Names only — the server configuration (command, env, URL, headers) never crosses the wire. + * @nullable + */ + relayed_mcp_servers?: RelayedMcpServerApi[] | null /** Execution environment for the new run. Use 'cloud' for remote sandbox runs and 'local' for desktop sessions. * * * `local` - local @@ -1881,6 +1950,7 @@ export const JsonrpcEnumApi = { * * `close` - close * * `permission_response` - permission_response * * `set_config_option` - set_config_option + * * `mcp_response` - mcp_response */ export type MethodEnumApi = (typeof MethodEnumApi)[keyof typeof MethodEnumApi] @@ -1890,6 +1960,7 @@ export const MethodEnumApi = { Close: 'close', PermissionResponse: 'permission_response', SetConfigOption: 'set_config_option', + McpResponse: 'mcp_response', } as const /** @@ -1906,7 +1977,8 @@ export interface TaskRunCommandRequestApi { * * `cancel` - cancel * * `close` - close * * `permission_response` - permission_response - * * `set_config_option` - set_config_option */ + * * `set_config_option` - set_config_option + * * `mcp_response` - mcp_response */ method: MethodEnumApi /** Parameters for the command */ params?: TaskRunCommandRequestApiParams diff --git a/products/tasks/frontend/generated/api.ts b/products/tasks/frontend/generated/api.ts index 4050ba8b3e1e..59b6f3d56b13 100644 --- a/products/tasks/frontend/generated/api.ts +++ b/products/tasks/frontend/generated/api.ts @@ -1175,7 +1175,7 @@ export const getTasksRunsCommandCreateUrl = (projectId: string, taskId: string, } /** - * Queue user_message JSON-RPC commands through the task workflow and forward sandbox control commands to the agent server. Supports user_message, cancel, close, permission_response, and set_config_option commands. + * Queue user_message JSON-RPC commands through the task workflow and forward sandbox control commands to the agent server. Supports user_message, cancel, close, permission_response, set_config_option, and mcp_response commands. * @summary Send command to task run */ export const tasksRunsCommandCreate = async ( diff --git a/products/tasks/frontend/generated/api.zod.ts b/products/tasks/frontend/generated/api.zod.ts index 71ea3c6c5219..e7c3a2ef2d28 100644 --- a/products/tasks/frontend/generated/api.zod.ts +++ b/products/tasks/frontend/generated/api.zod.ts @@ -773,11 +773,31 @@ export const TasksPresenceCreateBody = /* @__PURE__ */ zod * Create a new task run and kick off the workflow. * @summary Run task */ +export const tasksRunCreateBodyOneImportedMcpServersItemNameMax = 64 + +export const tasksRunCreateBodyOneImportedMcpServersItemUrlMax = 2048 + +export const tasksRunCreateBodyOneImportedMcpServersItemHeadersItemNameMax = 256 + +export const tasksRunCreateBodyOneImportedMcpServersItemHeadersItemValueMax = 4096 + +export const tasksRunCreateBodyOneRelayedMcpServersItemNameMax = 64 + export const tasksRunCreateBodyOneModeDefault = `background` export const tasksRunCreateBodyOneBranchMax = 255 export const tasksRunCreateBodyOnePendingUserArtifactIdsItemMax = 128 +export const tasksRunCreateBodyTwoImportedMcpServersItemNameMax = 64 + +export const tasksRunCreateBodyTwoImportedMcpServersItemUrlMax = 2048 + +export const tasksRunCreateBodyTwoImportedMcpServersItemHeadersItemNameMax = 256 + +export const tasksRunCreateBodyTwoImportedMcpServersItemHeadersItemValueMax = 4096 + +export const tasksRunCreateBodyTwoRelayedMcpServersItemNameMax = 64 + export const tasksRunCreateBodyTwoModeDefault = `background` export const tasksRunCreateBodyTwoBranchMax = 255 @@ -789,6 +809,46 @@ export const tasksRunCreateBodyThreeBranchMax = 255 export const TasksRunCreateBody = /* @__PURE__ */ zod.union([ zod .object({ + imported_mcp_servers: zod + .array( + zod + .object({ + type: zod.enum(['http', 'sse']).describe('\* `http` - http\n\* `sse` - sse'), + name: zod.string().max(tasksRunCreateBodyOneImportedMcpServersItemNameMax), + url: zod.url().max(tasksRunCreateBodyOneImportedMcpServersItemUrlMax), + headers: zod + .array( + zod.object({ + name: zod + .string() + .max(tasksRunCreateBodyOneImportedMcpServersItemHeadersItemNameMax), + value: zod + .string() + .max(tasksRunCreateBodyOneImportedMcpServersItemHeadersItemValueMax), + }) + ) + .optional(), + }) + .describe("One client-imported MCP server, in the agent server's --mcpServers entry shape.") + ) + .nullish() + .describe( + 'Local url-based MCP servers from the creating client (PostHog Code) to make available inside the cloud sandbox. Header values are treated as credentials: stored encrypted and never returned by the API.' + ), + relayed_mcp_servers: zod + .array( + zod + .object({ + name: zod.string().max(tasksRunCreateBodyOneRelayedMcpServersItemNameMax), + }) + .describe( + 'One desktop-only MCP server relayed into the run — a name only, never configuration.' + ) + ) + .nullish() + .describe( + 'Names of desktop-only MCP servers the creating client (PostHog Code) relays into the cloud sandbox over the durable event\/command channel. Names only — the server configuration (command, env, URL, headers) never crosses the wire.' + ), mode: zod .enum(['interactive', 'background']) .describe('\* `interactive` - interactive\n\* `background` - background') @@ -886,6 +946,46 @@ export const TasksRunCreateBody = /* @__PURE__ */ zod.union([ .describe('Request body for creating a new task run'), zod .object({ + imported_mcp_servers: zod + .array( + zod + .object({ + type: zod.enum(['http', 'sse']).describe('\* `http` - http\n\* `sse` - sse'), + name: zod.string().max(tasksRunCreateBodyTwoImportedMcpServersItemNameMax), + url: zod.url().max(tasksRunCreateBodyTwoImportedMcpServersItemUrlMax), + headers: zod + .array( + zod.object({ + name: zod + .string() + .max(tasksRunCreateBodyTwoImportedMcpServersItemHeadersItemNameMax), + value: zod + .string() + .max(tasksRunCreateBodyTwoImportedMcpServersItemHeadersItemValueMax), + }) + ) + .optional(), + }) + .describe("One client-imported MCP server, in the agent server's --mcpServers entry shape.") + ) + .nullish() + .describe( + 'Local url-based MCP servers from the creating client (PostHog Code) to make available inside the cloud sandbox. Header values are treated as credentials: stored encrypted and never returned by the API.' + ), + relayed_mcp_servers: zod + .array( + zod + .object({ + name: zod.string().max(tasksRunCreateBodyTwoRelayedMcpServersItemNameMax), + }) + .describe( + 'One desktop-only MCP server relayed into the run — a name only, never configuration.' + ) + ) + .nullish() + .describe( + 'Names of desktop-only MCP servers the creating client (PostHog Code) relays into the cloud sandbox over the durable event\/command channel. Names only — the server configuration (command, env, URL, headers) never crosses the wire.' + ), mode: zod .enum(['interactive', 'background']) .describe('\* `interactive` - interactive\n\* `background` - background') @@ -1233,6 +1333,16 @@ export const TasksStagedArtifactsPrepareUploadCreateBody = /* @__PURE__ */ zod.o * Create a new run for a specific task without starting execution. * @summary Create task run */ +export const tasksRunsCreateBodyImportedMcpServersItemNameMax = 64 + +export const tasksRunsCreateBodyImportedMcpServersItemUrlMax = 2048 + +export const tasksRunsCreateBodyImportedMcpServersItemHeadersItemNameMax = 256 + +export const tasksRunsCreateBodyImportedMcpServersItemHeadersItemValueMax = 4096 + +export const tasksRunsCreateBodyRelayedMcpServersItemNameMax = 64 + export const tasksRunsCreateBodyEnvironmentDefault = `local` export const tasksRunsCreateBodyModeDefault = `background` export const tasksRunsCreateBodyBranchMax = 255 @@ -1241,6 +1351,42 @@ export const tasksRunsCreateBodyHomeQuickActionMax = 120 export const TasksRunsCreateBody = /* @__PURE__ */ zod .object({ + imported_mcp_servers: zod + .array( + zod + .object({ + type: zod.enum(['http', 'sse']).describe('\* `http` - http\n\* `sse` - sse'), + name: zod.string().max(tasksRunsCreateBodyImportedMcpServersItemNameMax), + url: zod.url().max(tasksRunsCreateBodyImportedMcpServersItemUrlMax), + headers: zod + .array( + zod.object({ + name: zod.string().max(tasksRunsCreateBodyImportedMcpServersItemHeadersItemNameMax), + value: zod + .string() + .max(tasksRunsCreateBodyImportedMcpServersItemHeadersItemValueMax), + }) + ) + .optional(), + }) + .describe("One client-imported MCP server, in the agent server's --mcpServers entry shape.") + ) + .nullish() + .describe( + 'Local url-based MCP servers from the creating client (PostHog Code) to make available inside the cloud sandbox. Header values are treated as credentials: stored encrypted and never returned by the API.' + ), + relayed_mcp_servers: zod + .array( + zod + .object({ + name: zod.string().max(tasksRunsCreateBodyRelayedMcpServersItemNameMax), + }) + .describe('One desktop-only MCP server relayed into the run — a name only, never configuration.') + ) + .nullish() + .describe( + 'Names of desktop-only MCP servers the creating client (PostHog Code) relays into the cloud sandbox over the durable event\/command channel. Names only — the server configuration (command, env, URL, headers) never crosses the wire.' + ), environment: zod .enum(['local', 'cloud']) .describe('\* `local` - local\n\* `cloud` - cloud') @@ -1700,7 +1846,7 @@ export const TasksRunsCancelCreateBody = /* @__PURE__ */ zod.object({ }) /** - * Queue user_message JSON-RPC commands through the task workflow and forward sandbox control commands to the agent server. Supports user_message, cancel, close, permission_response, and set_config_option commands. + * Queue user_message JSON-RPC commands through the task workflow and forward sandbox control commands to the agent server. Supports user_message, cancel, close, permission_response, set_config_option, and mcp_response commands. * @summary Send command to task run */ export const TasksRunsCommandCreateBody = /* @__PURE__ */ zod @@ -1710,12 +1856,12 @@ export const TasksRunsCommandCreateBody = /* @__PURE__ */ zod .describe('\* `2.0` - 2.0') .describe("JSON-RPC version, must be '2.0'\n\n\* `2.0` - 2.0"), method: zod - .enum(['user_message', 'cancel', 'close', 'permission_response', 'set_config_option']) + .enum(['user_message', 'cancel', 'close', 'permission_response', 'set_config_option', 'mcp_response']) .describe( - '\* `user_message` - user_message\n\* `cancel` - cancel\n\* `close` - close\n\* `permission_response` - permission_response\n\* `set_config_option` - set_config_option' + '\* `user_message` - user_message\n\* `cancel` - cancel\n\* `close` - close\n\* `permission_response` - permission_response\n\* `set_config_option` - set_config_option\n\* `mcp_response` - mcp_response' ) .describe( - 'Command method to execute on the agent server\n\n\* `user_message` - user_message\n\* `cancel` - cancel\n\* `close` - close\n\* `permission_response` - permission_response\n\* `set_config_option` - set_config_option' + 'Command method to execute on the agent server\n\n\* `user_message` - user_message\n\* `cancel` - cancel\n\* `close` - close\n\* `permission_response` - permission_response\n\* `set_config_option` - set_config_option\n\* `mcp_response` - mcp_response' ), params: zod.record(zod.string(), zod.unknown()).optional().describe('Parameters for the command'), id: zod.unknown().optional().describe('Optional JSON-RPC request ID (string or number)'), diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index a7c5b571d42d..4043033bbd73 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -13477,6 +13477,45 @@ export namespace Schemas { Claude: 'claude', } as const; + /** + * * `http` - http + * * `sse` - sse + */ + export type ImportedMcpServerTypeEnum = typeof ImportedMcpServerTypeEnum[keyof typeof ImportedMcpServerTypeEnum]; + + + export const ImportedMcpServerTypeEnum = { + Http: 'http', + Sse: 'sse', + } as const; + + export interface ImportedMcpServerHeader { + /** @maxLength 256 */ + name: string; + /** @maxLength 4096 */ + value: string; + } + + /** + * One client-imported MCP server, in the agent server's --mcpServers entry shape. + */ + export interface ImportedMcpServer { + type: ImportedMcpServerTypeEnum; + /** @maxLength 64 */ + name: string; + /** @maxLength 2048 */ + url: string; + headers?: ImportedMcpServerHeader[]; + } + + /** + * One desktop-only MCP server relayed into the run — a name only, never configuration. + */ + export interface RelayedMcpServer { + /** @maxLength 64 */ + name: string; + } + /** * * `interactive` - interactive * * `background` - background @@ -13553,6 +13592,16 @@ export namespace Schemas { * Request body for creating a new task run */ export interface ClaudeTaskRunCreateSchema { + /** + * Local url-based MCP servers from the creating client (PostHog Code) to make available inside the cloud sandbox. Header values are treated as credentials: stored encrypted and never returned by the API. + * @nullable + */ + imported_mcp_servers?: ImportedMcpServer[] | null; + /** + * Names of desktop-only MCP servers the creating client (PostHog Code) relays into the cloud sandbox over the durable event/command channel. Names only — the server configuration (command, env, URL, headers) never crosses the wire. + * @nullable + */ + relayed_mcp_servers?: RelayedMcpServer[] | null; /** Execution mode: 'interactive' for user-connected runs, 'background' for autonomous runs * * * `interactive` - interactive @@ -13914,6 +13963,16 @@ export namespace Schemas { * Request body for creating a new task run */ export interface CodexTaskRunCreateSchema { + /** + * Local url-based MCP servers from the creating client (PostHog Code) to make available inside the cloud sandbox. Header values are treated as credentials: stored encrypted and never returned by the API. + * @nullable + */ + imported_mcp_servers?: ImportedMcpServer[] | null; + /** + * Names of desktop-only MCP servers the creating client (PostHog Code) relays into the cloud sandbox over the durable event/command channel. Names only — the server configuration (command, env, URL, headers) never crosses the wire. + * @nullable + */ + relayed_mcp_servers?: RelayedMcpServer[] | null; /** Execution mode: 'interactive' for user-connected runs, 'background' for autonomous runs * * * `interactive` - interactive @@ -34870,6 +34929,7 @@ export namespace Schemas { * * `close` - close * * `permission_response` - permission_response * * `set_config_option` - set_config_option + * * `mcp_response` - mcp_response */ export type MethodEnum = typeof MethodEnum[keyof typeof MethodEnum]; @@ -34880,6 +34940,7 @@ export namespace Schemas { Close: 'close', PermissionResponse: 'permission_response', SetConfigOption: 'set_config_option', + McpResponse: 'mcp_response', } as const; /** @@ -59939,6 +60000,16 @@ export namespace Schemas { * Request body for creating a task run without starting execution yet. */ export interface TaskRunBootstrapCreateRequest { + /** + * Local url-based MCP servers from the creating client (PostHog Code) to make available inside the cloud sandbox. Header values are treated as credentials: stored encrypted and never returned by the API. + * @nullable + */ + imported_mcp_servers?: ImportedMcpServer[] | null; + /** + * Names of desktop-only MCP servers the creating client (PostHog Code) relays into the cloud sandbox over the durable event/command channel. Names only — the server configuration (command, env, URL, headers) never crosses the wire. + * @nullable + */ + relayed_mcp_servers?: RelayedMcpServer[] | null; /** Execution environment for the new run. Use 'cloud' for remote sandbox runs and 'local' for desktop sessions. * * * `local` - local @@ -60043,7 +60114,8 @@ export namespace Schemas { * * `cancel` - cancel * * `close` - close * * `permission_response` - permission_response - * * `set_config_option` - set_config_option */ + * * `set_config_option` - set_config_option + * * `mcp_response` - mcp_response */ method: MethodEnum; /** Parameters for the command */ params?: TaskRunCommandRequestParams;