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