airbyte_ops_mcp.mcp.devin_secret_request

MCP tools for on-demand Devin secret request and discovery.

Tools:

  • list_devin_secrets: Lists all available secret names in the 1Password vault, so the agent can discover valid secret aliases.
  • request_devin_secret: Two-phase approval workflow to request and deliver a secret.

MCP reference

MCP primitives registered by the devin_secret_request module of the airbyte-internal-ops server: 2 tool(s), 0 prompt(s), 0 resource(s).

Tools (2)

list_devin_secrets

Hints: open-world

List all available secret names in the 1Password vault.

Returns the sorted list of item titles from the 'devin-on-demand-secrets' vault. Use this to discover valid secret aliases before calling request_devin_secret.

This dispatches a GitHub Actions workflow (which has the 1Password credentials), waits for it to complete, then reads the list from the job logs.

Parameters:

_No parameters._

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {},
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the list_devin_secrets tool.",
  "properties": {
    "success": {
      "description": "Whether the operation succeeded",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message",
      "type": "string"
    },
    "available_secrets": {
      "description": "Sorted list of available secret names in the vault",
      "items": {
        "type": "string"
      },
      "type": "array"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

request_devin_secret

Hints: open-world

Request a secret on demand via an approval workflow.

This tool operates in two phases:

Phase 1 (no approval_evidence_url): Dispatches a GitHub Actions workflow that validates the secret name against the 1Password vault and, if valid, sends a Slack approval request. If the secret name is not found, returns immediately with the list of available secret names so you can correct any typos.

Phase 2 (with approval_evidence_url): After a human approves the request, call this tool again with the approval evidence URL. This triggers a GitHub Actions workflow that reads the secret from 1Password and sends you a time-limited share link. Open the link in your browser to view and copy the secret.

Typical workflow:

  1. (Optional) Call list_devin_secrets first to see available names.
  2. Call this tool without approval_evidence_url to request approval.
  3. Note the request_id in the response.
  4. Wait for a human to approve the request in Slack.
  5. Obtain the approval evidence URL (Slack approval record URL).
  6. Call this tool again with the approval_evidence_url and the request_id from step 2.
  7. You will receive a 1Password share link -- open it in your browser to view and copy the secret values.

Parameters:

Name Type Required Default Description
secret_alias string yes The name of the secret to request. This must exactly match an item title in the 'devin-on-demand-secrets' 1Password vault.
session_url string yes Your Devin session URL (e.g. 'https://app.devin.ai/sessions/abc123...'). Use the session URL from your system prompt.
approval_evidence_url string | null no null Slack approval record URL (https://.slack.com/archives/...). Leave empty for Phase 1 (requesting approval). Provide the Slack URL for Phase 2 (delivering the secret after approval).
target_approver string | null no null Person to notify for approval (GitHub handle, email, or Slack user ID). Required for Phase 1 (approval request).
request_id string | null no null Request ID returned by Phase 1. Pass it back in Phase 2 so the approval record can be validated against the original request. Leave empty for Phase 1.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "secret_alias": {
      "description": "The name of the secret to request. This must exactly match an item title in the 'devin-on-demand-secrets' 1Password vault.",
      "type": "string"
    },
    "session_url": {
      "description": "Your Devin session URL (e.g. 'https://app.devin.ai/sessions/abc123...'). Use the session URL from your system prompt.",
      "type": "string"
    },
    "approval_evidence_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Slack approval record URL (https://<workspace>.slack.com/archives/...). Leave empty for Phase 1 (requesting approval). Provide the Slack URL for Phase 2 (delivering the secret after approval)."
    },
    "target_approver": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Person to notify for approval (GitHub handle, email, or Slack user ID). Required for Phase 1 (approval request)."
    },
    "request_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Request ID returned by Phase 1. Pass it back in Phase 2 so the approval record can be validated against the original request. Leave empty for Phase 1."
    }
  },
  "required": [
    "secret_alias",
    "session_url"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the request_devin_secret tool.",
  "properties": {
    "success": {
      "description": "Whether the operation succeeded",
      "type": "boolean"
    },
    "phase": {
      "description": "Current phase: 'approval_requested' (Phase 1) or 'delivery_dispatched' (Phase 2)",
      "type": "string"
    },
    "message": {
      "description": "Human-readable status message",
      "type": "string"
    },
    "slack_channel_url": {
      "default": "https://airbytehq-team.slack.com/archives/C0AEXV81Q7N",
      "description": "Direct URL to the #human-in-the-loop Slack channel",
      "type": "string"
    },
    "secret_alias": {
      "description": "The requested secret alias",
      "type": "string"
    },
    "session_id": {
      "description": "The Devin session ID",
      "type": "string"
    },
    "workflow_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "URL to the GitHub Actions workflow"
    },
    "run_id": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "GitHub Actions workflow run ID"
    },
    "run_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Direct URL to the GitHub Actions workflow run"
    },
    "request_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Unique request identifier (UUID). Returned in Phase 1; pass it back in Phase 2 for replay-protection validation."
    }
  },
  "required": [
    "success",
    "phase",
    "message",
    "secret_alias",
    "session_id"
  ],
  "type": "object"
}

  1# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
  2"""MCP tools for on-demand Devin secret request and discovery.
  3
  4Tools:
  5
  6- **list_devin_secrets**: Lists all available secret names in the
  7  1Password vault, so the agent can discover valid secret aliases.
  8- **request_devin_secret**: Two-phase approval workflow to request
  9  and deliver a secret.
 10
 11## MCP reference
 12
 13.. include:: ../../../docs/mcp-generated/devin_secret_request.md
 14    :start-line: 2
 15"""
 16
 17# NOTE: We intentionally do NOT use `from __future__ import annotations` here.
 18# FastMCP has issues resolving forward references when PEP 563 deferred annotations
 19# are used. See: https://github.com/jlowin/fastmcp/issues/905
 20
 21__all__: list[str] = []
 22
 23import json
 24import logging
 25import re
 26from typing import Annotated
 27
 28import requests
 29from fastmcp import FastMCP
 30from fastmcp_extensions import mcp_tool, register_mcp_tools
 31from pydantic import BaseModel, Field
 32
 33from airbyte_ops_mcp.github_actions import (
 34    download_job_logs,
 35    get_workflow_jobs,
 36    resolve_default_workflow_branch,
 37    trigger_workflow_dispatch,
 38    wait_for_workflow_completion,
 39)
 40from airbyte_ops_mcp.github_api import resolve_ci_trigger_github_token
 41from airbyte_ops_mcp.human_in_the_loop import HITL_SLACK_CHANNEL_URL
 42
 43logger = logging.getLogger(__name__)
 44
 45# ---------------------------------------------------------------------------
 46# Constants
 47# ---------------------------------------------------------------------------
 48
 49WORKFLOW_REPO_OWNER = "airbytehq"
 50WORKFLOW_REPO_NAME = "airbyte-ops-mcp"
 51WORKFLOW_FILE = "devin-secret-request.yml"
 52WORKFLOW_DEFAULT_BRANCH = "main"
 53
 54
 55_SESSION_ID_PATTERN = re.compile(r"[0-9a-fA-F]{32}")
 56
 57
 58class SecretListResponse(BaseModel):
 59    """Response from the list_devin_secrets tool."""
 60
 61    success: bool = Field(description="Whether the operation succeeded")
 62    message: str = Field(description="Human-readable status message")
 63    available_secrets: list[str] = Field(
 64        default_factory=list,
 65        description="Sorted list of available secret names in the vault",
 66    )
 67
 68
 69class SecretRequestResponse(BaseModel):
 70    """Response from the request_devin_secret tool."""
 71
 72    success: bool = Field(description="Whether the operation succeeded")
 73    phase: str = Field(
 74        description=(
 75            "Current phase: 'approval_requested' (Phase 1) or "
 76            "'delivery_dispatched' (Phase 2)"
 77        ),
 78    )
 79    message: str = Field(description="Human-readable status message")
 80    slack_channel_url: str = Field(
 81        default=HITL_SLACK_CHANNEL_URL,
 82        description="Direct URL to the #human-in-the-loop Slack channel",
 83    )
 84    secret_alias: str = Field(description="The requested secret alias")
 85    session_id: str = Field(description="The Devin session ID")
 86    workflow_url: str | None = Field(
 87        default=None,
 88        description="URL to the GitHub Actions workflow",
 89    )
 90    run_id: int | None = Field(
 91        default=None,
 92        description="GitHub Actions workflow run ID",
 93    )
 94    run_url: str | None = Field(
 95        default=None,
 96        description="Direct URL to the GitHub Actions workflow run",
 97    )
 98    request_id: str | None = Field(
 99        default=None,
100        description=(
101            "Unique request identifier (UUID). Returned in Phase 1; "
102            "pass it back in Phase 2 for replay-protection validation."
103        ),
104    )
105
106
107@mcp_tool(
108    read_only=False,
109    idempotent=False,
110    open_world=True,
111)
112def list_devin_secrets() -> SecretListResponse:
113    """List all available secret names in the 1Password vault.
114
115    Returns the sorted list of item titles from the
116    'devin-on-demand-secrets' vault. Use this to discover valid
117    secret aliases before calling request_devin_secret.
118
119    This dispatches a GitHub Actions workflow (which has the
120    1Password credentials), waits for it to complete, then reads
121    the list from the job logs.
122    """
123    return _list_secrets_via_workflow()
124
125
126@mcp_tool(
127    read_only=False,
128    idempotent=False,
129    open_world=True,
130)
131def request_devin_secret(
132    secret_alias: Annotated[
133        str,
134        "The name of the secret to request. This must exactly match an item "
135        "title in the 'devin-on-demand-secrets' 1Password vault.",
136    ],
137    session_url: Annotated[
138        str,
139        "Your Devin session URL (e.g. 'https://app.devin.ai/sessions/abc123...'). "
140        "Use the session URL from your system prompt.",
141    ],
142    approval_evidence_url: Annotated[
143        str | None,
144        "Slack approval record URL "
145        "(https://<workspace>.slack.com/archives/...). "
146        "Leave empty for Phase 1 (requesting approval). Provide the "
147        "Slack URL for Phase 2 (delivering the secret after approval).",
148    ] = None,
149    target_approver: Annotated[
150        str | None,
151        "Person to notify for approval (GitHub handle, email, or Slack user ID). "
152        "Required for Phase 1 (approval request).",
153    ] = None,
154    request_id: Annotated[
155        str | None,
156        "Request ID returned by Phase 1. Pass it back in Phase 2 "
157        "so the approval record can be validated against the original request. "
158        "Leave empty for Phase 1.",
159    ] = None,
160) -> SecretRequestResponse:
161    """Request a secret on demand via an approval workflow.
162
163    This tool operates in two phases:
164
165    **Phase 1** (no approval_evidence_url): Dispatches a GitHub Actions
166    workflow that validates the secret name against the 1Password vault
167    and, if valid, sends a Slack approval request. If the secret name is
168    not found, returns immediately with the list of available secret
169    names so you can correct any typos.
170
171    **Phase 2** (with approval_evidence_url): After a human approves the
172    request, call this tool again with the approval evidence URL. This
173    triggers a GitHub Actions workflow that reads the secret from
174    1Password and sends you a time-limited share link.
175    Open the link in your browser to view and copy the secret.
176
177    Typical workflow:
178    0. (Optional) Call list_devin_secrets first to see available names.
179    1. Call this tool without approval_evidence_url to request approval.
180    2. Note the `request_id` in the response.
181    3. Wait for a human to approve the request in Slack.
182    4. Obtain the approval evidence URL (Slack approval record URL).
183    5. Call this tool again with the approval_evidence_url **and** the
184       request_id from step 2.
185    6. You will receive a 1Password share link -- open it in your
186       browser to view and copy the secret values.
187    """
188    # Extract session ID from URL
189    match = _SESSION_ID_PATTERN.search(session_url)
190    if not match:
191        return SecretRequestResponse(
192            success=False,
193            phase="error",
194            message=(
195                f"No valid session ID found in URL: {session_url}. "
196                "Expected a 32-character hex string."
197            ),
198            secret_alias=secret_alias,
199            session_id="",
200        )
201    session_id = match.group(0)
202
203    if not approval_evidence_url:
204        # Phase 1: Dispatch the request workflow (validates secret name
205        # inline using op CLI, then sends Slack approval if valid).
206        if not target_approver:
207            return SecretRequestResponse(
208                success=False,
209                phase="error",
210                message=(
211                    "target_approver is required when requesting approval "
212                    "(no approval_evidence_url provided)."
213                ),
214                secret_alias=secret_alias,
215                session_id=session_id,
216            )
217
218        return _request_secret_via_workflow(
219            secret_alias=secret_alias,
220            session_id=session_id,
221            session_url=session_url,
222            target_approver=target_approver,
223        )
224
225    # Phase 2: Deliver secret via GitHub Actions workflow
226    token = resolve_ci_trigger_github_token()
227
228    workflow_inputs: dict[str, str] = {
229        "action": "deliver",
230        "secret_alias": secret_alias,
231        "session_id": session_id,
232        "approval_evidence_url": approval_evidence_url,
233    }
234    if request_id:
235        workflow_inputs["expected_request_id"] = request_id
236
237    result = trigger_workflow_dispatch(
238        owner=WORKFLOW_REPO_OWNER,
239        repo=WORKFLOW_REPO_NAME,
240        workflow_file=WORKFLOW_FILE,
241        ref=resolve_default_workflow_branch(WORKFLOW_DEFAULT_BRANCH),
242        inputs=workflow_inputs,
243        token=token,
244    )
245
246    view_url = result.run_url or result.workflow_url
247    return SecretRequestResponse(
248        success=True,
249        phase="delivery_dispatched",
250        message=(
251            f"Secret delivery workflow dispatched for '{secret_alias}'. "
252            f"The workflow will read the secret from 1Password and send "
253            f"you a time-limited share link. Once you receive the link, "
254            f"open it in your browser to view and copy the secret. "
255            f"View progress: {view_url}"
256        ),
257        secret_alias=secret_alias,
258        session_id=session_id,
259        workflow_url=result.workflow_url,
260        run_id=result.run_id,
261        run_url=result.run_url,
262        request_id=request_id,
263    )
264
265
266def _request_secret_via_workflow(
267    secret_alias: str,
268    session_id: str,
269    session_url: str,
270    target_approver: str,
271) -> SecretRequestResponse:
272    """Dispatch the request workflow, wait, and parse the result from job logs.
273
274    The workflow validates the secret alias against the vault inline,
275    then sends the Slack approval if valid.  On a bad alias the workflow
276    fails and the job logs contain a JSON object with `available_secrets`.
277    """
278    token = resolve_ci_trigger_github_token()
279
280    dispatch_result = trigger_workflow_dispatch(
281        owner=WORKFLOW_REPO_OWNER,
282        repo=WORKFLOW_REPO_NAME,
283        workflow_file=WORKFLOW_FILE,
284        ref=resolve_default_workflow_branch(WORKFLOW_DEFAULT_BRANCH),
285        inputs={
286            "action": "request",
287            "secret_alias": secret_alias,
288            "session_id": session_id,
289            "target_approver": target_approver,
290        },
291        token=token,
292    )
293    if not dispatch_result.run_id:
294        return SecretRequestResponse(
295            success=False,
296            phase="error",
297            message=(
298                "Workflow dispatched but no run ID returned. "
299                f"Check: {dispatch_result.workflow_url}"
300            ),
301            secret_alias=secret_alias,
302            session_id=session_id,
303            workflow_url=dispatch_result.workflow_url,
304        )
305
306    run_status = wait_for_workflow_completion(
307        owner=WORKFLOW_REPO_OWNER,
308        repo=WORKFLOW_REPO_NAME,
309        run_id=dispatch_result.run_id,
310        token=token,
311    )
312
313    # Download logs from the validation job (multi-job workflow)
314    raw_logs = _download_run_logs(
315        dispatch_result.run_id, token, job_name="Validate Secret Name"
316    )
317
318    if run_status.succeeded:
319        # Parse the approval-requested JSON from the logs
320        result_data = _find_json_in_logs(raw_logs, "phase") if raw_logs else None
321        request_id = result_data.get("request_id") if result_data else None
322        view_url = run_status.run_url or dispatch_result.workflow_url
323        return SecretRequestResponse(
324            success=True,
325            phase="approval_requested",
326            message=(
327                f"Approval request for secret '{secret_alias}' sent to "
328                f"#human-in-the-loop ({HITL_SLACK_CHANNEL_URL}). "
329                f"Waiting for human approval. "
330                f"Once approved, call this tool again with the "
331                f"approval_evidence_url to deliver the secret. "
332                f"View progress: {view_url}"
333            ),
334            secret_alias=secret_alias,
335            session_id=session_id,
336            workflow_url=dispatch_result.workflow_url,
337            run_id=dispatch_result.run_id,
338            run_url=run_status.run_url,
339            request_id=request_id,
340        )
341
342    # Workflow failed — check if it was a validation failure
343    error_data = _find_json_in_logs(raw_logs, "available_secrets") if raw_logs else None
344    if error_data:
345        available = error_data.get("available_secrets", [])
346        formatted = ", ".join(f"`{s}`" for s in available)
347        return SecretRequestResponse(
348            success=False,
349            phase="validation_failed",
350            message=(
351                f"Secret '{secret_alias}' not found in the vault. "
352                f"Available secrets: {formatted}"
353            ),
354            secret_alias=secret_alias,
355            session_id=session_id,
356            workflow_url=dispatch_result.workflow_url,
357            run_id=dispatch_result.run_id,
358            run_url=run_status.run_url,
359        )
360
361    # Generic workflow failure
362    return SecretRequestResponse(
363        success=False,
364        phase="error",
365        message=(
366            f"Request workflow failed (conclusion={run_status.conclusion}). "
367            f"See: {run_status.run_url}"
368        ),
369        secret_alias=secret_alias,
370        session_id=session_id,
371        workflow_url=dispatch_result.workflow_url,
372        run_id=dispatch_result.run_id,
373        run_url=run_status.run_url,
374    )
375
376
377def _download_run_logs(
378    run_id: int,
379    token: str,
380    *,
381    job_name: str | None = None,
382) -> str | None:
383    """Best-effort download of a job's logs for a workflow run.
384
385    Args:
386        run_id: GitHub Actions workflow run ID.
387        token: GitHub API token used for log download. Note: job listing
388            uses `get_workflow_jobs` which resolves its own token via
389            `resolve_ci_trigger_github_token()`.
390        job_name: If provided, find the job whose name contains this
391            substring (case-insensitive). Skipped jobs are always
392            excluded. Falls back to the first non-skipped job.
393    """
394    try:
395        jobs = get_workflow_jobs(
396            owner=WORKFLOW_REPO_OWNER,
397            repo=WORKFLOW_REPO_NAME,
398            run_id=run_id,
399        )
400        # Filter out skipped jobs (common in multi-job conditional workflows)
401        active_jobs = [j for j in jobs if j.conclusion != "skipped"]
402        if not active_jobs:
403            return None
404
405        target = active_jobs[0]  # default: first non-skipped job
406        if job_name:
407            needle = job_name.lower()
408            for j in active_jobs:
409                if needle in j.name.lower():
410                    target = j
411                    break
412
413        return download_job_logs(
414            owner=WORKFLOW_REPO_OWNER,
415            repo=WORKFLOW_REPO_NAME,
416            job_id=target.job_id,
417            token=token,
418        )
419    except (requests.HTTPError, ValueError) as exc:
420        logger.warning("Failed to download job logs for run %s: %s", run_id, exc)
421        return None
422
423
424def _list_secrets_via_workflow() -> SecretListResponse:
425    """Dispatch the list workflow, wait for completion, and parse titles from job logs."""
426    token = resolve_ci_trigger_github_token()
427
428    # 1. Dispatch the workflow with action="list"
429    dispatch_result = trigger_workflow_dispatch(
430        owner=WORKFLOW_REPO_OWNER,
431        repo=WORKFLOW_REPO_NAME,
432        workflow_file=WORKFLOW_FILE,
433        ref=resolve_default_workflow_branch(WORKFLOW_DEFAULT_BRANCH),
434        inputs={"action": "list", "session_id": "0" * 32},
435        token=token,
436    )
437    if not dispatch_result.run_id:
438        return SecretListResponse(
439            success=False,
440            message=(
441                "Workflow dispatched but no run ID returned. "
442                f"Check: {dispatch_result.workflow_url}"
443            ),
444        )
445
446    # 2. Wait for the workflow to complete
447    run_status = wait_for_workflow_completion(
448        owner=WORKFLOW_REPO_OWNER,
449        repo=WORKFLOW_REPO_NAME,
450        run_id=dispatch_result.run_id,
451        token=token,
452    )
453    if not run_status.succeeded:
454        return SecretListResponse(
455            success=False,
456            message=(
457                f"Workflow run failed (conclusion={run_status.conclusion}). "
458                f"See: {run_status.run_url}"
459            ),
460        )
461
462    # 3. Find the job and download its logs
463    jobs = get_workflow_jobs(
464        owner=WORKFLOW_REPO_OWNER,
465        repo=WORKFLOW_REPO_NAME,
466        run_id=dispatch_result.run_id,
467    )
468    if not jobs:
469        return SecretListResponse(
470            success=False,
471            message="Workflow completed but no jobs found.",
472        )
473
474    # Find the list job (multi-job workflow; skip skipped jobs)
475    active_jobs = [j for j in jobs if j.conclusion != "skipped"]
476    if not active_jobs:
477        return SecretListResponse(
478            success=False,
479            message="Workflow completed but all jobs were skipped.",
480        )
481
482    target_job = active_jobs[0]
483    for j in active_jobs:
484        if "list" in j.name.lower():
485            target_job = j
486            break
487
488    raw_logs = download_job_logs(
489        owner=WORKFLOW_REPO_OWNER,
490        repo=WORKFLOW_REPO_NAME,
491        job_id=target_job.job_id,
492        token=token,
493    )
494
495    # 4. Parse JSON output from the logs
496    data = _find_json_in_logs(raw_logs, "available_secrets")
497    if data is None:
498        return SecretListResponse(
499            success=False,
500            message=(
501                "Could not parse secret list from workflow logs. "
502                f"See: {run_status.run_url}"
503            ),
504        )
505
506    secrets = data.get("available_secrets", [])
507    titles = [str(t) for t in secrets] if isinstance(secrets, list) else []
508    return SecretListResponse(
509        success=True,
510        message=f"Found {len(titles)} available secrets in the vault.",
511        available_secrets=sorted(titles),
512    )
513
514
515def _find_json_in_logs(raw_logs: str, required_key: str) -> dict | None:
516    """Find the first JSON object in job logs that contains *required_key*.
517
518    GitHub Actions job logs prefix each line with a timestamp.  We scan
519    every line looking for a JSON object that contains the given key.
520    Returns the parsed dict, or `None` if not found.
521    """
522    for line in raw_logs.splitlines():
523        stripped = line.strip()
524        if not stripped.startswith("{"):
525            idx = stripped.find("{")
526            if idx < 0:
527                continue
528            stripped = stripped[idx:]
529        try:
530            data = json.loads(stripped)
531        except json.JSONDecodeError:
532            continue
533        if isinstance(data, dict) and required_key in data:
534            return data
535    return None
536
537
538def register_devin_secret_request_tools(app: FastMCP) -> None:
539    """Register Devin secret request tools with the FastMCP app."""
540    register_mcp_tools(app, mcp_module=__name__)