airbyte_ops_mcp.mcp.devin_ops

MCP tools for Devin agent-session operations: reminders, on-demand secret requests, session feedback, and session naming.

MCP reference

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

Tools (7)

cancel_devin_reminder

Hints: open-world

Cancel pending Devin reminders by session URL and specific GUIDs.

Removes matching reminders so they will not fire. Use this when instructed to stop reminders, or when a reminder is no longer needed.

Both agent_session_url and cancel_guids are required. Only reminders matching the session URL AND present in the GUID list are cancelled.

Parameters:

Name Type Required Default Description
agent_session_url string yes Your Devin session URL. Use the session URL from your system prompt. Required together with cancel_guids.
cancel_guids array<string> yes List of reminder GUIDs to cancel. You can get GUIDs from the reminder creation response or from the reminders list.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "agent_session_url": {
      "description": "Your Devin session URL. Use the session URL from your system prompt. Required together with cancel_guids.",
      "type": "string"
    },
    "cancel_guids": {
      "description": "List of reminder GUIDs to cancel. You can get GUIDs from the reminder creation response or from the reminders list.",
      "items": {
        "type": "string"
      },
      "type": "array"
    }
  },
  "required": [
    "agent_session_url",
    "cancel_guids"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the cancel_devin_reminder tool.",
  "properties": {
    "success": {
      "description": "Whether the cancel workflow was triggered successfully",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message",
      "type": "string"
    },
    "workflow_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "URL to view the GitHub Actions workflow file"
    },
    "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"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

devin_session_feedback

Hints: open-world

Report structured feedback about a Devin session experience via Slack.

Posts a formatted feedback message to the #hydra-feedback Slack channel, tagging the reporting user and @AJ Steers. The message includes a clickable button for the Devin session link. For negative feedback, a triage workflow is automatically dispatched to launch a Devin session with v3 analyze mode that can inspect the original session's full conversation history.

IMPORTANT: This feedback will be logged publicly in Slack. Inform the user that their feedback is visible to the team and they may be contacted for additional details.

Use this tool when a user explicitly asks to report a positive or negative experience with their Devin session. Before calling this tool, let the user know:

  • Their feedback will be posted publicly in the #hydra-feedback Slack channel
  • They may be contacted by the team for more details
  • Both the reporting user and @AJ Steers will be tagged in the message
  • For negative feedback, a triage session will be automatically launched to inspect the reported session

The Slack message is sent by a GitHub Actions workflow so that Slack credentials are never exposed to the calling agent.

Parameters:

Name Type Required Default Description
feedback_type enum("positive", "negative") yes Type of feedback: 'positive' for a good experience or 'negative' for a bad experience. Use 'positive' when the user expresses satisfaction, praise, or a success story. Use 'negative' when the user reports a problem, frustration, or failure.
category enum("tool_failure", "missing_guidance", "suspected_hallucination", "bad_approach", "excessive_iteration", "poor_quality", "other_concern", "great_results", "exceeded_expectations", "fast_completion", "good_communication", "other_positive_feedback") yes Feedback category. For NEGATIVE feedback, use one of: 'tool_failure' (a specific tool/integration broke), 'missing_guidance' (Devin lacked instructions or context), 'suspected_hallucination' (Devin fabricated information or made incorrect claims), 'bad_approach' (Devin took a fundamentally wrong strategy), 'excessive_iteration' (too many loops/retries before success), 'poor_quality' (output quality below expectations), 'other_concern'. For POSITIVE feedback, use one of: 'great_results' (task completed with high quality), 'exceeded_expectations' (went above and beyond), 'fast_completion' (completed quickly and efficiently), 'good_communication' (kept user well-informed), 'other_positive_feedback'.
task_description string yes Brief description of what the user asked Devin to do. This sets the context for the feedback.
agent_session_url string yes Your agent session URL so the team can view the full context. Use the session URL from your system prompt.
reporting_user string yes The person providing the feedback. Accepts an email address (e.g. 'aj@airbyte.io'), a GitHub handle prefixed with @ (e.g. '@aaronsteers'), or a Slack user ID (e.g. 'U05AKF1BCC9').
session_playbook string yes ID of the Devin playbook associated with the session (e.g. 'devin_feedback_triage'), or 'none' when no playbook is associated. Required so feedback can identify whether playbook instructions may need updates.
related_skill_name string | null no null Optional skill ID associated with the feedback (e.g. 'delete-declarative-source-def') when a related skill may need updates or is suspected of having issues.
expected_behavior string | null no null What should have happened. REQUIRED for negative feedback. Describe the expected outcome clearly.
observed_behavior string | null no null What actually happened. REQUIRED for negative feedback. Describe the actual outcome, including any error messages or unexpected results.
what_went_well string | null no null What specifically was good about the experience. REQUIRED for positive feedback. Be specific about what Devin did well.
severity enum("low", "medium", "high", "critical") | null no null Severity of the issue. Recommended for negative feedback. 'low' = minor inconvenience, 'medium' = notable impact, 'high' = significant blocker, 'critical' = complete failure.
steps_to_reproduce string | null no null Optional steps to reproduce the issue. Helpful for negative feedback to enable the team to investigate.
session_to_evaluate string | null no null Optional Devin session URL to evaluate/triage. Use this when reporting feedback about a different session (not your own). If omitted, agent_session_url is used as the session to triage (i.e., the reporter is reporting on itself).

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "feedback_type": {
      "description": "Type of feedback: 'positive' for a good experience or 'negative' for a bad experience. Use 'positive' when the user expresses satisfaction, praise, or a success story. Use 'negative' when the user reports a problem, frustration, or failure.",
      "enum": [
        "positive",
        "negative"
      ],
      "type": "string"
    },
    "category": {
      "description": "Feedback category. For NEGATIVE feedback, use one of: 'tool_failure' (a specific tool/integration broke), 'missing_guidance' (Devin lacked instructions or context), 'suspected_hallucination' (Devin fabricated information or made incorrect claims), 'bad_approach' (Devin took a fundamentally wrong strategy), 'excessive_iteration' (too many loops/retries before success), 'poor_quality' (output quality below expectations), 'other_concern'. For POSITIVE feedback, use one of: 'great_results' (task completed with high quality), 'exceeded_expectations' (went above and beyond), 'fast_completion' (completed quickly and efficiently), 'good_communication' (kept user well-informed), 'other_positive_feedback'.",
      "enum": [
        "tool_failure",
        "missing_guidance",
        "suspected_hallucination",
        "bad_approach",
        "excessive_iteration",
        "poor_quality",
        "other_concern",
        "great_results",
        "exceeded_expectations",
        "fast_completion",
        "good_communication",
        "other_positive_feedback"
      ],
      "type": "string"
    },
    "task_description": {
      "description": "Brief description of what the user asked Devin to do. This sets the context for the feedback.",
      "type": "string"
    },
    "agent_session_url": {
      "description": "Your agent session URL so the team can view the full context. Use the session URL from your system prompt.",
      "type": "string"
    },
    "reporting_user": {
      "description": "The person providing the feedback. Accepts an email address (e.g. 'aj@airbyte.io'), a GitHub handle prefixed with @ (e.g. '@aaronsteers'), or a Slack user ID (e.g. 'U05AKF1BCC9').",
      "type": "string"
    },
    "session_playbook": {
      "description": "ID of the Devin playbook associated with the session (e.g. 'devin_feedback_triage'), or 'none' when no playbook is associated. Required so feedback can identify whether playbook instructions may need updates.",
      "type": "string"
    },
    "related_skill_name": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional skill ID associated with the feedback (e.g. 'delete-declarative-source-def') when a related skill may need updates or is suspected of having issues."
    },
    "expected_behavior": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "What should have happened. REQUIRED for negative feedback. Describe the expected outcome clearly."
    },
    "observed_behavior": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "What actually happened. REQUIRED for negative feedback. Describe the actual outcome, including any error messages or unexpected results."
    },
    "what_went_well": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "What specifically was good about the experience. REQUIRED for positive feedback. Be specific about what Devin did well."
    },
    "severity": {
      "anyOf": [
        {
          "enum": [
            "low",
            "medium",
            "high",
            "critical"
          ],
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Severity of the issue. Recommended for negative feedback. 'low' = minor inconvenience, 'medium' = notable impact, 'high' = significant blocker, 'critical' = complete failure."
    },
    "steps_to_reproduce": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional steps to reproduce the issue. Helpful for negative feedback to enable the team to investigate."
    },
    "session_to_evaluate": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional Devin session URL to evaluate/triage. Use this when reporting feedback about a *different* session (not your own). If omitted, agent_session_url is used as the session to triage (i.e., the reporter is reporting on itself)."
    }
  },
  "required": [
    "feedback_type",
    "category",
    "task_description",
    "agent_session_url",
    "reporting_user",
    "session_playbook"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the session feedback tool.",
  "properties": {
    "success": {
      "description": "Whether the workflow was triggered successfully",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message",
      "type": "string"
    },
    "workflow_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "URL to view the GitHub Actions workflow file"
    },
    "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"
    },
    "triage_run_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "URL to the auto-triage workflow run"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

devin_session_feedback_followup

Hints: open-world

Post a follow-up to an existing feedback thread in #hydra-feedback.

This is the "second call" in the feedback workflow: after devin_session_feedback creates the initial report, this tool appends triage findings or additional context as a threaded reply.

Each reply is wrapped with a disclaimer clarifying that the thread is non-interactive and not monitored by any agent.

Workspace validation ensures only URLs from the expected Slack workspace are accepted.

Parameters:

Name Type Required Default Description
thread_url string yes Slack thread URL from the original feedback post in #hydra-feedback. This is the thread where follow-up context will be appended. Example: https://airbytehq-team.slack.com/archives/C0ACUHRP6B1/p1773062711122019
message string yes Follow-up message text in Slack mrkdwn format. Typically a triage report or additional context about the feedback being investigated. Supports bold, _italic_, code, code blocks, > blockquotes, and links.
agent_session_url string yes Your agent session URL for audit trail. Use the session URL from your system prompt.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "thread_url": {
      "description": "Slack thread URL from the original feedback post in #hydra-feedback. This is the thread where follow-up context will be appended. Example: https://airbytehq-team.slack.com/archives/C0ACUHRP6B1/p1773062711122019",
      "type": "string"
    },
    "message": {
      "description": "Follow-up message text in Slack mrkdwn format. Typically a triage report or additional context about the feedback being investigated. Supports *bold*, _italic_, `code`, ```code blocks```, > blockquotes, and <url|label> links.",
      "type": "string"
    },
    "agent_session_url": {
      "description": "Your agent session URL for audit trail. Use the session URL from your system prompt.",
      "type": "string"
    }
  },
  "required": [
    "thread_url",
    "message",
    "agent_session_url"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the session feedback follow-up tool.",
  "properties": {
    "success": {
      "description": "Whether the follow-up was posted successfully",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message",
      "type": "string"
    },
    "reply_ts": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Timestamp of the posted reply (Slack ts format)"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

get_devin_session_name

Hints: read-only · idempotent

Look up the deterministic friendly name for a Devin session.

Uses the silly-buddy naming scheme to generate a Title Case two-word name (e.g. "Smelly Fred") from the session ID. The output is immutable and idempotent — the same session ID always yields the same name.

If a full URL is provided instead of a bare ID, the session ID is extracted from the URL automatically.

Parameters:

Name Type Required Default Description
session_id string yes The Devin session identifier or session URL. Accepts a raw session ID (e.g. 'b2a641e838214f91b50d0f88940ac119') or a full session URL (e.g. 'https://app.devin.ai/sessions/b2a641e8...'). The ID is extracted automatically from URLs. The same ID always produces the same name — this is a deterministic lookup, not a creation.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "session_id": {
      "description": "The Devin session identifier or session URL. Accepts a raw session ID (e.g. 'b2a641e838214f91b50d0f88940ac119') or a full session URL (e.g. 'https://app.devin.ai/sessions/b2a641e8...'). The ID is extracted automatically from URLs. The same ID always produces the same name \u2014 this is a deterministic lookup, not a creation.",
      "type": "string"
    }
  },
  "required": [
    "session_id"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the Devin session naming tool.",
  "properties": {
    "session_id": {
      "description": "The input session ID",
      "type": "string"
    },
    "scheme_version": {
      "description": "The naming scheme version identifier",
      "type": "string"
    },
    "name": {
      "description": "The generated human-friendly session name in Title Case",
      "type": "string"
    },
    "full_name": {
      "description": "The contextual full name including 'Devin' suffix (e.g. 'Silly Fred Devin')",
      "type": "string"
    }
  },
  "required": [
    "session_id",
    "scheme_version",
    "name",
    "full_name"
  ],
  "type": "object"
}

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"
}

set_devin_reminder

Hints: open-world

Schedule a reminder that fires at a specified time or after a delay.

Creates a reminder that will be delivered back to your Devin session and posted to the #devin-reminders Slack channel when the time arrives. Reminders are checked every 30 minutes via a cron schedule.

Exactly one of delay_minutes or remind_at_local_time must be provided. Prefer remind_at_local_time (Pacific local time) over delay_minutes to avoid timezone-conversion mistakes — unless the user explicitly asks for a reminder in N minutes.

The reminder is stored as a GitHub Actions artifact and processed by the devin-reminders-action. When the reminder is due, it injects a message into the originating Devin session and sends a Slack notification.

Use this tool when you need to schedule a follow-up action, check on a long-running process, or remind yourself about a task.

Parameters:

Name Type Required Default Description
reminder_message string yes The reminder message to deliver. Should clearly describe what you need to be reminded about.
agent_session_url string yes Your Devin session URL so the reminder can be injected back into your session. Use the session URL from your system prompt.
delay_minutes integer | null no null Number of minutes until the reminder fires. Must be a positive multiple of 30, up to 10080 (7 days). Examples: 30, 60, 120, 1440. Mutually exclusive with remind_at_local_time.
remind_at_local_time string | null no null Date-time in local time when the reminder should fire. At Airbyte, local time is always Pacific (America/Los_Angeles). Accepts '2026-04-02 09:00' (24-hour), '2026-04-02 9:00 AM' (12-hour), or ISO-like 'YYYY-MM-DDTHH:MM'. Must be in the future and within 7 days. Mutually exclusive with delay_minutes. PREFERRED — use this instead of delay_minutes to avoid timezone-conversion errors.
slack_users_cc string | null no null Optional comma-delimited list of Slack user tags to CC on the reminder notification. Example: '<@U12345>, <@U67890>'.

Show input JSON schema

{
  "additionalProperties": false,
  "properties": {
    "reminder_message": {
      "description": "The reminder message to deliver. Should clearly describe what you need to be reminded about.",
      "type": "string"
    },
    "agent_session_url": {
      "description": "Your Devin session URL so the reminder can be injected back into your session. Use the session URL from your system prompt.",
      "type": "string"
    },
    "delay_minutes": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Number of minutes until the reminder fires. Must be a positive multiple of 30, up to 10080 (7 days). Examples: 30, 60, 120, 1440. Mutually exclusive with remind_at_local_time."
    },
    "remind_at_local_time": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Date-time in local time when the reminder should fire. At Airbyte, local time is always Pacific (America/Los_Angeles). Accepts '2026-04-02 09:00' (24-hour), '2026-04-02 9:00 AM' (12-hour), or ISO-like 'YYYY-MM-DDTHH:MM'. Must be in the future and within 7 days. Mutually exclusive with delay_minutes. PREFERRED \u2014 use this instead of delay_minutes to avoid timezone-conversion errors."
    },
    "slack_users_cc": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional comma-delimited list of Slack user tags to CC on the reminder notification. Example: '<@U12345>, <@U67890>'."
    }
  },
  "required": [
    "reminder_message",
    "agent_session_url"
  ],
  "type": "object"
}

Show output JSON schema

{
  "description": "Response from the set_devin_reminder tool.",
  "properties": {
    "success": {
      "description": "Whether the workflow was triggered successfully",
      "type": "boolean"
    },
    "message": {
      "description": "Human-readable status message",
      "type": "string"
    },
    "workflow_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "URL to view the GitHub Actions workflow file"
    },
    "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"
    }
  },
  "required": [
    "success",
    "message"
  ],
  "type": "object"
}

   1# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
   2"""MCP tools for Devin agent-session operations: reminders, on-demand secret requests, session feedback, and session naming.
   3
   4## MCP reference
   5
   6.. include:: ../../../docs/mcp-generated/devin_ops.md
   7    :start-line: 2
   8"""
   9
  10# NOTE: We intentionally do NOT use `from __future__ import annotations` here.
  11# FastMCP has issues resolving forward references when PEP 563 deferred annotations
  12# are used. See: https://github.com/jlowin/fastmcp/issues/905
  13# Python 3.12+ supports modern type hint syntax natively, so this is not needed.
  14
  15__all__: list[str] = []
  16
  17import json
  18import logging
  19import re
  20from enum import StrEnum
  21from typing import Annotated, Literal
  22
  23import requests
  24from fastmcp import FastMCP
  25from fastmcp_extensions import mcp_tool, register_mcp_tools
  26from pydantic import BaseModel, Field
  27
  28from airbyte_ops_mcp.devin_reminders import dispatch_cancel_reminder, dispatch_reminder
  29from airbyte_ops_mcp.github_actions import (
  30    WorkflowDispatchResult,
  31    download_job_logs,
  32    get_workflow_jobs,
  33    resolve_default_workflow_branch,
  34    trigger_workflow_dispatch,
  35    wait_for_workflow_completion,
  36)
  37from airbyte_ops_mcp.github_api import resolve_ci_trigger_github_token
  38from airbyte_ops_mcp.human_in_the_loop import (
  39    HITL_SLACK_CHANNEL_URL,
  40    dispatch_escalation,
  41)
  42from airbyte_ops_mcp.session_namer import (
  43    NamingScheme,
  44    extract_session_id,
  45    generate_friendly_name,
  46)
  47from airbyte_ops_mcp.slack_api import SlackAPIError, SlackURLParseError
  48from airbyte_ops_mcp.slack_posting import parse_slack_thread_url, post_thread_reply
  49
  50
  51class SetDevinReminderResponse(BaseModel):
  52    """Response from the set_devin_reminder tool."""
  53
  54    success: bool = Field(description="Whether the workflow was triggered successfully")
  55    message: str = Field(description="Human-readable status message")
  56    workflow_url: str | None = Field(
  57        default=None,
  58        description="URL to view the GitHub Actions workflow file",
  59    )
  60    run_id: int | None = Field(
  61        default=None,
  62        description="GitHub Actions workflow run ID",
  63    )
  64    run_url: str | None = Field(
  65        default=None,
  66        description="Direct URL to the GitHub Actions workflow run",
  67    )
  68
  69
  70@mcp_tool(
  71    read_only=False,
  72    idempotent=False,
  73    open_world=True,
  74)
  75def set_devin_reminder(
  76    reminder_message: Annotated[
  77        str,
  78        "The reminder message to deliver. Should clearly describe what "
  79        "you need to be reminded about.",
  80    ],
  81    agent_session_url: Annotated[
  82        str,
  83        "Your Devin session URL so the reminder can be injected back into "
  84        "your session. Use the session URL from your system prompt.",
  85    ],
  86    delay_minutes: Annotated[
  87        int | None,
  88        "Number of minutes until the reminder fires. Must be a positive "
  89        "multiple of 30, up to 10080 (7 days). Examples: 30, 60, 120, 1440. "
  90        "Mutually exclusive with remind_at_local_time.",
  91    ] = None,
  92    remind_at_local_time: Annotated[
  93        str | None,
  94        "Date-time in local time when the reminder should fire. "
  95        "At Airbyte, local time is always Pacific (America/Los_Angeles). "
  96        "Accepts '2026-04-02 09:00' (24-hour), "
  97        "'2026-04-02 9:00 AM' (12-hour), or ISO-like 'YYYY-MM-DDTHH:MM'. "
  98        "Must be in the future and within 7 days. "
  99        "Mutually exclusive with delay_minutes. "
 100        "PREFERRED — use this instead of delay_minutes to avoid "
 101        "timezone-conversion errors.",
 102    ] = None,
 103    slack_users_cc: Annotated[
 104        str | None,
 105        "Optional comma-delimited list of Slack user tags to CC on the "
 106        "reminder notification. Example: '<@U12345>, <@U67890>'.",
 107    ] = None,
 108) -> SetDevinReminderResponse:
 109    """Schedule a reminder that fires at a specified time or after a delay.
 110
 111    Creates a reminder that will be delivered back to your Devin session
 112    and posted to the #devin-reminders Slack channel when the time arrives.
 113    Reminders are checked every 30 minutes via a cron schedule.
 114
 115    Exactly one of `delay_minutes` or `remind_at_local_time` must be provided.
 116    Prefer `remind_at_local_time` (Pacific local time) over `delay_minutes`
 117    to avoid timezone-conversion mistakes — unless the user explicitly
 118    asks for a reminder in N minutes.
 119
 120    The reminder is stored as a GitHub Actions artifact and processed by
 121    the devin-reminders-action. When the reminder is due, it injects a
 122    message into the originating Devin session and sends a Slack notification.
 123
 124    Use this tool when you need to schedule a follow-up action, check on
 125    a long-running process, or remind yourself about a task.
 126    """
 127    try:
 128        result = dispatch_reminder(
 129            delay_minutes=delay_minutes,
 130            remind_at_local_time=remind_at_local_time,
 131            reminder_message=reminder_message,
 132            agent_session_url=agent_session_url,
 133            slack_users_cc=slack_users_cc,
 134        )
 135    except ValueError as e:
 136        return SetDevinReminderResponse(
 137            success=False,
 138            message=f"Invalid input: {e}",
 139        )
 140
 141    if remind_at_local_time:
 142        time_desc = f"at {remind_at_local_time} Pacific"
 143    else:
 144        time_desc = f"in {delay_minutes} minutes"
 145
 146    view_url = result.run_url or result.workflow_url
 147    return SetDevinReminderResponse(
 148        success=True,
 149        message=(
 150            f"Reminder scheduled to fire {time_desc}. "
 151            f"View progress at: {view_url}\n\n"
 152            f"To cancel pending reminders for this session, use the "
 153            f"`cancel_devin_reminder` tool."
 154        ),
 155        workflow_url=result.workflow_url,
 156        run_id=result.run_id,
 157        run_url=result.run_url,
 158    )
 159
 160
 161class CancelDevinReminderResponse(BaseModel):
 162    """Response from the cancel_devin_reminder tool."""
 163
 164    success: bool = Field(
 165        description="Whether the cancel workflow was triggered successfully"
 166    )
 167    message: str = Field(description="Human-readable status message")
 168    workflow_url: str | None = Field(
 169        default=None,
 170        description="URL to view the GitHub Actions workflow file",
 171    )
 172    run_id: int | None = Field(
 173        default=None,
 174        description="GitHub Actions workflow run ID",
 175    )
 176    run_url: str | None = Field(
 177        default=None,
 178        description="Direct URL to the GitHub Actions workflow run",
 179    )
 180
 181
 182@mcp_tool(
 183    read_only=False,
 184    idempotent=False,
 185    open_world=True,
 186)
 187def cancel_devin_reminder(
 188    agent_session_url: Annotated[
 189        str,
 190        "Your Devin session URL. Use the session URL from your system prompt. "
 191        "Required together with cancel_guids.",
 192    ],
 193    cancel_guids: Annotated[
 194        list[str],
 195        "List of reminder GUIDs to cancel. You can get GUIDs from "
 196        "the reminder creation response or from the reminders list.",
 197    ],
 198) -> CancelDevinReminderResponse:
 199    """Cancel pending Devin reminders by session URL and specific GUIDs.
 200
 201    Removes matching reminders so they will not fire. Use this when instructed
 202    to stop reminders, or when a reminder is no longer needed.
 203
 204    Both agent_session_url and cancel_guids are required. Only reminders
 205    matching the session URL AND present in the GUID list are cancelled.
 206    """
 207    try:
 208        result = dispatch_cancel_reminder(
 209            agent_session_url=agent_session_url,
 210            cancel_guids=cancel_guids,
 211        )
 212    except ValueError as e:
 213        return CancelDevinReminderResponse(
 214            success=False,
 215            message=f"Invalid input: {e}",
 216        )
 217
 218    view_url = result.run_url or result.workflow_url
 219    guid_list = ", ".join(cancel_guids)
 220    return CancelDevinReminderResponse(
 221        success=True,
 222        message=(
 223            f"Cancel workflow triggered for GUIDs [{guid_list}] "
 224            f"in session {agent_session_url}. View progress at: {view_url}"
 225        ),
 226        workflow_url=result.workflow_url,
 227        run_id=result.run_id,
 228        run_url=result.run_url,
 229    )
 230
 231
 232logger = logging.getLogger(__name__)
 233
 234WORKFLOW_REPO_OWNER = "airbytehq"
 235
 236WORKFLOW_REPO_NAME = "airbyte-ops-mcp"
 237
 238WORKFLOW_FILE = "devin-secret-request.yml"
 239
 240WORKFLOW_DEFAULT_BRANCH = "main"
 241
 242_SESSION_ID_PATTERN = re.compile(r"[0-9a-fA-F]{32}")
 243
 244
 245class SecretListResponse(BaseModel):
 246    """Response from the list_devin_secrets tool."""
 247
 248    success: bool = Field(description="Whether the operation succeeded")
 249    message: str = Field(description="Human-readable status message")
 250    available_secrets: list[str] = Field(
 251        default_factory=list,
 252        description="Sorted list of available secret names in the vault",
 253    )
 254
 255
 256class SecretRequestResponse(BaseModel):
 257    """Response from the request_devin_secret tool."""
 258
 259    success: bool = Field(description="Whether the operation succeeded")
 260    phase: str = Field(
 261        description=(
 262            "Current phase: 'approval_requested' (Phase 1) or "
 263            "'delivery_dispatched' (Phase 2)"
 264        ),
 265    )
 266    message: str = Field(description="Human-readable status message")
 267    slack_channel_url: str = Field(
 268        default=HITL_SLACK_CHANNEL_URL,
 269        description="Direct URL to the #human-in-the-loop Slack channel",
 270    )
 271    secret_alias: str = Field(description="The requested secret alias")
 272    session_id: str = Field(description="The Devin session ID")
 273    workflow_url: str | None = Field(
 274        default=None,
 275        description="URL to the GitHub Actions workflow",
 276    )
 277    run_id: int | None = Field(
 278        default=None,
 279        description="GitHub Actions workflow run ID",
 280    )
 281    run_url: str | None = Field(
 282        default=None,
 283        description="Direct URL to the GitHub Actions workflow run",
 284    )
 285    request_id: str | None = Field(
 286        default=None,
 287        description=(
 288            "Unique request identifier (UUID). Returned in Phase 1; "
 289            "pass it back in Phase 2 for replay-protection validation."
 290        ),
 291    )
 292
 293
 294@mcp_tool(
 295    read_only=False,
 296    idempotent=False,
 297    open_world=True,
 298)
 299def list_devin_secrets() -> SecretListResponse:
 300    """List all available secret names in the 1Password vault.
 301
 302    Returns the sorted list of item titles from the
 303    'devin-on-demand-secrets' vault. Use this to discover valid
 304    secret aliases before calling request_devin_secret.
 305
 306    This dispatches a GitHub Actions workflow (which has the
 307    1Password credentials), waits for it to complete, then reads
 308    the list from the job logs.
 309    """
 310    return _list_secrets_via_workflow()
 311
 312
 313@mcp_tool(
 314    read_only=False,
 315    idempotent=False,
 316    open_world=True,
 317)
 318def request_devin_secret(
 319    secret_alias: Annotated[
 320        str,
 321        "The name of the secret to request. This must exactly match an item "
 322        "title in the 'devin-on-demand-secrets' 1Password vault.",
 323    ],
 324    session_url: Annotated[
 325        str,
 326        "Your Devin session URL (e.g. 'https://app.devin.ai/sessions/abc123...'). "
 327        "Use the session URL from your system prompt.",
 328    ],
 329    approval_evidence_url: Annotated[
 330        str | None,
 331        "Slack approval record URL "
 332        "(https://<workspace>.slack.com/archives/...). "
 333        "Leave empty for Phase 1 (requesting approval). Provide the "
 334        "Slack URL for Phase 2 (delivering the secret after approval).",
 335    ] = None,
 336    target_approver: Annotated[
 337        str | None,
 338        "Person to notify for approval (GitHub handle, email, or Slack user ID). "
 339        "Required for Phase 1 (approval request).",
 340    ] = None,
 341    request_id: Annotated[
 342        str | None,
 343        "Request ID returned by Phase 1. Pass it back in Phase 2 "
 344        "so the approval record can be validated against the original request. "
 345        "Leave empty for Phase 1.",
 346    ] = None,
 347) -> SecretRequestResponse:
 348    """Request a secret on demand via an approval workflow.
 349
 350    This tool operates in two phases:
 351
 352    **Phase 1** (no approval_evidence_url): Dispatches a GitHub Actions
 353    workflow that validates the secret name against the 1Password vault
 354    and, if valid, sends a Slack approval request. If the secret name is
 355    not found, returns immediately with the list of available secret
 356    names so you can correct any typos.
 357
 358    **Phase 2** (with approval_evidence_url): After a human approves the
 359    request, call this tool again with the approval evidence URL. This
 360    triggers a GitHub Actions workflow that reads the secret from
 361    1Password and sends you a time-limited share link.
 362    Open the link in your browser to view and copy the secret.
 363
 364    Typical workflow:
 365    0. (Optional) Call list_devin_secrets first to see available names.
 366    1. Call this tool without approval_evidence_url to request approval.
 367    2. Note the `request_id` in the response.
 368    3. Wait for a human to approve the request in Slack.
 369    4. Obtain the approval evidence URL (Slack approval record URL).
 370    5. Call this tool again with the approval_evidence_url **and** the
 371       request_id from step 2.
 372    6. You will receive a 1Password share link -- open it in your
 373       browser to view and copy the secret values.
 374    """
 375    # Extract session ID from URL
 376    match = _SESSION_ID_PATTERN.search(session_url)
 377    if not match:
 378        return SecretRequestResponse(
 379            success=False,
 380            phase="error",
 381            message=(
 382                f"No valid session ID found in URL: {session_url}. "
 383                "Expected a 32-character hex string."
 384            ),
 385            secret_alias=secret_alias,
 386            session_id="",
 387        )
 388    session_id = match.group(0)
 389
 390    if not approval_evidence_url:
 391        # Phase 1: Dispatch the request workflow (validates secret name
 392        # inline using op CLI, then sends Slack approval if valid).
 393        if not target_approver:
 394            return SecretRequestResponse(
 395                success=False,
 396                phase="error",
 397                message=(
 398                    "target_approver is required when requesting approval "
 399                    "(no approval_evidence_url provided)."
 400                ),
 401                secret_alias=secret_alias,
 402                session_id=session_id,
 403            )
 404
 405        return _request_secret_via_workflow(
 406            secret_alias=secret_alias,
 407            session_id=session_id,
 408            session_url=session_url,
 409            target_approver=target_approver,
 410        )
 411
 412    # Phase 2: Deliver secret via GitHub Actions workflow
 413    token = resolve_ci_trigger_github_token()
 414
 415    workflow_inputs: dict[str, str] = {
 416        "action": "deliver",
 417        "secret_alias": secret_alias,
 418        "session_id": session_id,
 419        "approval_evidence_url": approval_evidence_url,
 420    }
 421    if request_id:
 422        workflow_inputs["expected_request_id"] = request_id
 423
 424    result = trigger_workflow_dispatch(
 425        owner=WORKFLOW_REPO_OWNER,
 426        repo=WORKFLOW_REPO_NAME,
 427        workflow_file=WORKFLOW_FILE,
 428        ref=resolve_default_workflow_branch(WORKFLOW_DEFAULT_BRANCH),
 429        inputs=workflow_inputs,
 430        token=token,
 431    )
 432
 433    view_url = result.run_url or result.workflow_url
 434    return SecretRequestResponse(
 435        success=True,
 436        phase="delivery_dispatched",
 437        message=(
 438            f"Secret delivery workflow dispatched for '{secret_alias}'. "
 439            f"The workflow will read the secret from 1Password and send "
 440            f"you a time-limited share link. Once you receive the link, "
 441            f"open it in your browser to view and copy the secret. "
 442            f"View progress: {view_url}"
 443        ),
 444        secret_alias=secret_alias,
 445        session_id=session_id,
 446        workflow_url=result.workflow_url,
 447        run_id=result.run_id,
 448        run_url=result.run_url,
 449        request_id=request_id,
 450    )
 451
 452
 453def _request_secret_via_workflow(
 454    secret_alias: str,
 455    session_id: str,
 456    session_url: str,
 457    target_approver: str,
 458) -> SecretRequestResponse:
 459    """Dispatch the request workflow, wait, and parse the result from job logs.
 460
 461    The workflow validates the secret alias against the vault inline,
 462    then sends the Slack approval if valid.  On a bad alias the workflow
 463    fails and the job logs contain a JSON object with `available_secrets`.
 464    """
 465    token = resolve_ci_trigger_github_token()
 466
 467    dispatch_result = trigger_workflow_dispatch(
 468        owner=WORKFLOW_REPO_OWNER,
 469        repo=WORKFLOW_REPO_NAME,
 470        workflow_file=WORKFLOW_FILE,
 471        ref=resolve_default_workflow_branch(WORKFLOW_DEFAULT_BRANCH),
 472        inputs={
 473            "action": "request",
 474            "secret_alias": secret_alias,
 475            "session_id": session_id,
 476            "target_approver": target_approver,
 477        },
 478        token=token,
 479    )
 480    if not dispatch_result.run_id:
 481        return SecretRequestResponse(
 482            success=False,
 483            phase="error",
 484            message=(
 485                "Workflow dispatched but no run ID returned. "
 486                f"Check: {dispatch_result.workflow_url}"
 487            ),
 488            secret_alias=secret_alias,
 489            session_id=session_id,
 490            workflow_url=dispatch_result.workflow_url,
 491        )
 492
 493    run_status = wait_for_workflow_completion(
 494        owner=WORKFLOW_REPO_OWNER,
 495        repo=WORKFLOW_REPO_NAME,
 496        run_id=dispatch_result.run_id,
 497        token=token,
 498    )
 499
 500    # Download logs from the validation job (multi-job workflow)
 501    raw_logs = _download_run_logs(
 502        dispatch_result.run_id, token, job_name="Validate Secret Name"
 503    )
 504
 505    if run_status.succeeded:
 506        # Parse the approval-requested JSON from the logs
 507        result_data = _find_json_in_logs(raw_logs, "phase") if raw_logs else None
 508        request_id = result_data.get("request_id") if result_data else None
 509        view_url = run_status.run_url or dispatch_result.workflow_url
 510        return SecretRequestResponse(
 511            success=True,
 512            phase="approval_requested",
 513            message=(
 514                f"Approval request for secret '{secret_alias}' sent to "
 515                f"#human-in-the-loop ({HITL_SLACK_CHANNEL_URL}). "
 516                f"Waiting for human approval. "
 517                f"Once approved, call this tool again with the "
 518                f"approval_evidence_url to deliver the secret. "
 519                f"View progress: {view_url}"
 520            ),
 521            secret_alias=secret_alias,
 522            session_id=session_id,
 523            workflow_url=dispatch_result.workflow_url,
 524            run_id=dispatch_result.run_id,
 525            run_url=run_status.run_url,
 526            request_id=request_id,
 527        )
 528
 529    # Workflow failed — check if it was a validation failure
 530    error_data = _find_json_in_logs(raw_logs, "available_secrets") if raw_logs else None
 531    if error_data:
 532        available = error_data.get("available_secrets", [])
 533        formatted = ", ".join(f"`{s}`" for s in available)
 534        return SecretRequestResponse(
 535            success=False,
 536            phase="validation_failed",
 537            message=(
 538                f"Secret '{secret_alias}' not found in the vault. "
 539                f"Available secrets: {formatted}"
 540            ),
 541            secret_alias=secret_alias,
 542            session_id=session_id,
 543            workflow_url=dispatch_result.workflow_url,
 544            run_id=dispatch_result.run_id,
 545            run_url=run_status.run_url,
 546        )
 547
 548    # Generic workflow failure
 549    return SecretRequestResponse(
 550        success=False,
 551        phase="error",
 552        message=(
 553            f"Request workflow failed (conclusion={run_status.conclusion}). "
 554            f"See: {run_status.run_url}"
 555        ),
 556        secret_alias=secret_alias,
 557        session_id=session_id,
 558        workflow_url=dispatch_result.workflow_url,
 559        run_id=dispatch_result.run_id,
 560        run_url=run_status.run_url,
 561    )
 562
 563
 564def _download_run_logs(
 565    run_id: int,
 566    token: str,
 567    *,
 568    job_name: str | None = None,
 569) -> str | None:
 570    """Best-effort download of a job's logs for a workflow run.
 571
 572    Args:
 573        run_id: GitHub Actions workflow run ID.
 574        token: GitHub API token used for log download. Note: job listing
 575            uses `get_workflow_jobs` which resolves its own token via
 576            `resolve_ci_trigger_github_token()`.
 577        job_name: If provided, find the job whose name contains this
 578            substring (case-insensitive). Skipped jobs are always
 579            excluded. Falls back to the first non-skipped job.
 580    """
 581    try:
 582        jobs = get_workflow_jobs(
 583            owner=WORKFLOW_REPO_OWNER,
 584            repo=WORKFLOW_REPO_NAME,
 585            run_id=run_id,
 586        )
 587        # Filter out skipped jobs (common in multi-job conditional workflows)
 588        active_jobs = [j for j in jobs if j.conclusion != "skipped"]
 589        if not active_jobs:
 590            return None
 591
 592        target = active_jobs[0]  # default: first non-skipped job
 593        if job_name:
 594            needle = job_name.lower()
 595            for j in active_jobs:
 596                if needle in j.name.lower():
 597                    target = j
 598                    break
 599
 600        return download_job_logs(
 601            owner=WORKFLOW_REPO_OWNER,
 602            repo=WORKFLOW_REPO_NAME,
 603            job_id=target.job_id,
 604            token=token,
 605        )
 606    except (requests.HTTPError, ValueError) as exc:
 607        logger.warning("Failed to download job logs for run %s: %s", run_id, exc)
 608        return None
 609
 610
 611def _list_secrets_via_workflow() -> SecretListResponse:
 612    """Dispatch the list workflow, wait for completion, and parse titles from job logs."""
 613    token = resolve_ci_trigger_github_token()
 614
 615    # 1. Dispatch the workflow with action="list"
 616    dispatch_result = trigger_workflow_dispatch(
 617        owner=WORKFLOW_REPO_OWNER,
 618        repo=WORKFLOW_REPO_NAME,
 619        workflow_file=WORKFLOW_FILE,
 620        ref=resolve_default_workflow_branch(WORKFLOW_DEFAULT_BRANCH),
 621        inputs={"action": "list", "session_id": "0" * 32},
 622        token=token,
 623    )
 624    if not dispatch_result.run_id:
 625        return SecretListResponse(
 626            success=False,
 627            message=(
 628                "Workflow dispatched but no run ID returned. "
 629                f"Check: {dispatch_result.workflow_url}"
 630            ),
 631        )
 632
 633    # 2. Wait for the workflow to complete
 634    run_status = wait_for_workflow_completion(
 635        owner=WORKFLOW_REPO_OWNER,
 636        repo=WORKFLOW_REPO_NAME,
 637        run_id=dispatch_result.run_id,
 638        token=token,
 639    )
 640    if not run_status.succeeded:
 641        return SecretListResponse(
 642            success=False,
 643            message=(
 644                f"Workflow run failed (conclusion={run_status.conclusion}). "
 645                f"See: {run_status.run_url}"
 646            ),
 647        )
 648
 649    # 3. Find the job and download its logs
 650    jobs = get_workflow_jobs(
 651        owner=WORKFLOW_REPO_OWNER,
 652        repo=WORKFLOW_REPO_NAME,
 653        run_id=dispatch_result.run_id,
 654    )
 655    if not jobs:
 656        return SecretListResponse(
 657            success=False,
 658            message="Workflow completed but no jobs found.",
 659        )
 660
 661    # Find the list job (multi-job workflow; skip skipped jobs)
 662    active_jobs = [j for j in jobs if j.conclusion != "skipped"]
 663    if not active_jobs:
 664        return SecretListResponse(
 665            success=False,
 666            message="Workflow completed but all jobs were skipped.",
 667        )
 668
 669    target_job = active_jobs[0]
 670    for j in active_jobs:
 671        if "list" in j.name.lower():
 672            target_job = j
 673            break
 674
 675    raw_logs = download_job_logs(
 676        owner=WORKFLOW_REPO_OWNER,
 677        repo=WORKFLOW_REPO_NAME,
 678        job_id=target_job.job_id,
 679        token=token,
 680    )
 681
 682    # 4. Parse JSON output from the logs
 683    data = _find_json_in_logs(raw_logs, "available_secrets")
 684    if data is None:
 685        return SecretListResponse(
 686            success=False,
 687            message=(
 688                "Could not parse secret list from workflow logs. "
 689                f"See: {run_status.run_url}"
 690            ),
 691        )
 692
 693    secrets = data.get("available_secrets", [])
 694    titles = [str(t) for t in secrets] if isinstance(secrets, list) else []
 695    return SecretListResponse(
 696        success=True,
 697        message=f"Found {len(titles)} available secrets in the vault.",
 698        available_secrets=sorted(titles),
 699    )
 700
 701
 702def _find_json_in_logs(raw_logs: str, required_key: str) -> dict | None:
 703    """Find the first JSON object in job logs that contains *required_key*.
 704
 705    GitHub Actions job logs prefix each line with a timestamp.  We scan
 706    every line looking for a JSON object that contains the given key.
 707    Returns the parsed dict, or `None` if not found.
 708    """
 709    for line in raw_logs.splitlines():
 710        stripped = line.strip()
 711        if not stripped.startswith("{"):
 712            idx = stripped.find("{")
 713            if idx < 0:
 714                continue
 715            stripped = stripped[idx:]
 716        try:
 717            data = json.loads(stripped)
 718        except json.JSONDecodeError:
 719            continue
 720        if isinstance(data, dict) and required_key in data:
 721            return data
 722    return None
 723
 724
 725_FEEDBACK_CHANNEL = "C0ACUHRP6B1"
 726
 727_AJ_STEERS_IDENTIFIER = "U05AKF1BCC9"
 728
 729_TRIAGE_REPO_OWNER = "airbytehq"
 730
 731_TRIAGE_REPO_NAME = "airbyte-ops-mcp"
 732
 733_TRIAGE_WORKFLOW_FILE = "devin-session-triage.yml"
 734
 735_TRIAGE_DEFAULT_BRANCH = "main"
 736
 737_AI_SKILLS_REPO_URL = "https://github.com/airbytehq/ai-skills"
 738
 739_INTERNAL_SKILLS_URL = (
 740    "https://internal.airbyte.ai/docs/internal-docs/ai-engineering/skills"
 741)
 742
 743_PLAYBOOK_ID_PATTERN = re.compile(r"^[a-z0-9_-]+$")
 744
 745_SKILL_ID_PATTERN = re.compile(r"^[a-z0-9-]+$")
 746
 747_CATEGORY_DISPLAY: dict[str, str] = {
 748    "tool_failure": "Tool Failure",
 749    "missing_guidance": "Missing Guidance",
 750    "suspected_hallucination": "Suspected Hallucination",
 751    "bad_approach": "Bad Approach",
 752    "excessive_iteration": "Excessive Iteration",
 753    "poor_quality": "Poor Quality",
 754    "other_concern": "Other Concern",
 755    "great_results": "Great Results",
 756    "exceeded_expectations": "Exceeded Expectations",
 757    "fast_completion": "Fast Completion",
 758    "good_communication": "Good Communication",
 759    "other_positive_feedback": "Other Positive Feedback",
 760}
 761
 762
 763class FeedbackCategory(StrEnum):
 764    """Feedback categories for Devin session reports."""
 765
 766    # Negative categories
 767    TOOL_FAILURE = "tool_failure"
 768    MISSING_GUIDANCE = "missing_guidance"
 769    SUSPECTED_HALLUCINATION = "suspected_hallucination"
 770    BAD_APPROACH = "bad_approach"
 771    EXCESSIVE_ITERATION = "excessive_iteration"
 772    POOR_QUALITY = "poor_quality"
 773    OTHER_CONCERN = "other_concern"
 774
 775    # Positive categories
 776    GREAT_RESULTS = "great_results"
 777    EXCEEDED_EXPECTATIONS = "exceeded_expectations"
 778    FAST_COMPLETION = "fast_completion"
 779    GOOD_COMMUNICATION = "good_communication"
 780    OTHER_POSITIVE_FEEDBACK = "other_positive_feedback"
 781
 782    def is_negative(self) -> bool:
 783        """Return True if this is a negative feedback category."""
 784        return self in _NEGATIVE_MEMBERS
 785
 786    def display_name(self) -> str:
 787        """Return the human-readable display name for this category."""
 788        return _CATEGORY_DISPLAY.get(self.value, self.value)
 789
 790
 791_NEGATIVE_MEMBERS = frozenset(
 792    {
 793        FeedbackCategory.TOOL_FAILURE,
 794        FeedbackCategory.MISSING_GUIDANCE,
 795        FeedbackCategory.SUSPECTED_HALLUCINATION,
 796        FeedbackCategory.BAD_APPROACH,
 797        FeedbackCategory.EXCESSIVE_ITERATION,
 798        FeedbackCategory.POOR_QUALITY,
 799        FeedbackCategory.OTHER_CONCERN,
 800    }
 801)
 802
 803_SEVERITY_DISPLAY: dict[str, str] = {
 804    "low": "Low",
 805    "medium": "Medium",
 806    "high": "High",
 807    "critical": "Critical",
 808}
 809
 810
 811def _feedback_emoji(feedback_type: str) -> str:
 812    """Return the header emoji for the given feedback type."""
 813    return ":tada:" if feedback_type == "positive" else ":warning:"
 814
 815
 816def _feedback_label(feedback_type: str) -> str:
 817    """Return the header label for the given feedback type."""
 818    type_display = "Positive" if feedback_type == "positive" else "Negative"
 819    return f"Devin Session Feedback ({type_display})"
 820
 821
 822def _format_playbook_link(playbook_id: str) -> str:
 823    """Return Slack mrkdwn for a playbook identifier."""
 824    if playbook_id == "none":
 825        return "none"
 826    return f"<{_AI_SKILLS_REPO_URL}/blob/main/devin/playbooks/{playbook_id}.md|{playbook_id}>"
 827
 828
 829def _format_skill_link(skill_id: str) -> str:
 830    """Return Slack mrkdwn for a skill identifier."""
 831    return f"<{_INTERNAL_SKILLS_URL}/#{skill_id}|{skill_id}>"
 832
 833
 834def _validate_playbook_id(playbook_id: str) -> str | None:
 835    """Return an error message if `playbook_id` is not a valid playbook identifier."""
 836    if playbook_id == "none" or _PLAYBOOK_ID_PATTERN.fullmatch(playbook_id):
 837        return None
 838    return "session_playbook must be 'none' or a lowercase playbook ID using only letters, numbers, '-' and '_'."
 839
 840
 841def _validate_skill_id(skill_id: str | None) -> str | None:
 842    """Return an error message if `skill_id` is not a valid skill identifier."""
 843    if skill_id is None or _SKILL_ID_PATTERN.fullmatch(skill_id):
 844        return None
 845    return "related_skill_name must be a lowercase skill ID using only letters, numbers, and '-'."
 846
 847
 848def _build_feedback_body(
 849    *,
 850    feedback_type: str,
 851    category: str,
 852    task_description: str,
 853    session_playbook: str,
 854    related_skill_name: str | None,
 855    expected_behavior: str | None,
 856    observed_behavior: str | None,
 857    what_went_well: str | None,
 858    severity: str | None,
 859    steps_to_reproduce: str | None,
 860) -> str:
 861    """Build a Slack mrkdwn message body from structured feedback fields."""
 862    lines: list[str] = []
 863
 864    cat = FeedbackCategory(category)
 865    lines.append(f"*Category:* {cat.display_name()}")
 866
 867    if severity:
 868        sev_display = _SEVERITY_DISPLAY.get(severity, severity)
 869        lines.append(f"*Severity:* {sev_display}")
 870
 871    lines.append("")
 872    lines.append(f"*Task:* {task_description}")
 873    lines.append(f"*Session Playbook:* {_format_playbook_link(session_playbook)}")
 874    if related_skill_name:
 875        lines.append(f"*Related Skill:* {_format_skill_link(related_skill_name)}")
 876
 877    if feedback_type == "negative":
 878        if expected_behavior:
 879            lines.append("")
 880            lines.append(f"*Expected Behavior:* {expected_behavior}")
 881        if observed_behavior:
 882            lines.append("")
 883            lines.append(f"*Observed Behavior:* {observed_behavior}")
 884        if steps_to_reproduce:
 885            lines.append("")
 886            lines.append(f"*Steps to Reproduce:* {steps_to_reproduce}")
 887    else:
 888        if what_went_well:
 889            lines.append("")
 890            lines.append(f"*What Went Well:* {what_went_well}")
 891
 892    if feedback_type == "negative":
 893        lines.append("")
 894        lines.append(
 895            "_Auto-triage: a Devin session with v3 analyze mode will inspect this session._"
 896        )
 897
 898    return "\n".join(lines)
 899
 900
 901def _validate_negative_fields(
 902    expected_behavior: str | None,
 903    observed_behavior: str | None,
 904) -> str | None:
 905    """Return an error message if required negative feedback fields are missing."""
 906    missing: list[str] = []
 907    if not expected_behavior:
 908        missing.append("expected_behavior")
 909    if not observed_behavior:
 910        missing.append("observed_behavior")
 911    if missing:
 912        return f"Negative feedback requires: {', '.join(missing)}."
 913    return None
 914
 915
 916def _validate_positive_fields(
 917    what_went_well: str | None,
 918) -> str | None:
 919    """Return an error message if required positive feedback fields are missing."""
 920    if not what_went_well:
 921        return "Positive feedback requires: what_went_well."
 922    return None
 923
 924
 925def _dispatch_triage_workflow(
 926    session_url: str,
 927    feedback_context: str,
 928    reporting_user: str,
 929    session_playbook: str,
 930    related_skill_name: str | None = None,
 931    cc_persons: str = "",
 932    header_emoji: str = "",
 933    header_label: str = "",
 934) -> WorkflowDispatchResult | None:
 935    """Dispatch the v3 session triage workflow.
 936
 937    The triage workflow launches a Devin session with v3 analyze mode and
 938    posts a single Slack notification via the HITL reusable workflow.
 939    Formatting params (emoji, header, cc) are passed through to the HITL
 940    notification so the caller doesn't need to post separately.
 941
 942    Returns the dispatch result, or None if dispatch fails.
 943    """
 944    token = resolve_ci_trigger_github_token()
 945    inputs: dict[str, str] = {
 946        "session_url": session_url,
 947        "feedback_context": feedback_context,
 948        "reporting_user": reporting_user,
 949        "session_playbook": session_playbook,
 950    }
 951    if related_skill_name:
 952        inputs["related_skill_name"] = related_skill_name
 953    if cc_persons:
 954        inputs["cc_persons"] = cc_persons
 955    if header_emoji:
 956        inputs["header_emoji"] = header_emoji
 957    if header_label:
 958        inputs["header_label"] = header_label
 959    try:
 960        return trigger_workflow_dispatch(
 961            owner=_TRIAGE_REPO_OWNER,
 962            repo=_TRIAGE_REPO_NAME,
 963            workflow_file=_TRIAGE_WORKFLOW_FILE,
 964            ref=resolve_default_workflow_branch(_TRIAGE_DEFAULT_BRANCH),
 965            inputs=inputs,
 966            token=token,
 967        )
 968    except requests.HTTPError:
 969        logger.exception("Failed to dispatch triage workflow")
 970        return None
 971
 972
 973class SessionFeedbackResponse(BaseModel):
 974    """Response from the session feedback tool."""
 975
 976    success: bool = Field(description="Whether the workflow was triggered successfully")
 977    message: str = Field(description="Human-readable status message")
 978    workflow_url: str | None = Field(
 979        default=None,
 980        description="URL to view the GitHub Actions workflow file",
 981    )
 982    run_id: int | None = Field(
 983        default=None,
 984        description="GitHub Actions workflow run ID",
 985    )
 986    run_url: str | None = Field(
 987        default=None,
 988        description="Direct URL to the GitHub Actions workflow run",
 989    )
 990    triage_run_url: str | None = Field(
 991        default=None,
 992        description="URL to the auto-triage workflow run",
 993    )
 994
 995
 996@mcp_tool(
 997    read_only=False,
 998    idempotent=False,
 999    open_world=True,
1000)
1001def devin_session_feedback(
1002    feedback_type: Annotated[
1003        Literal["positive", "negative"],
1004        Field(
1005            description=(
1006                "Type of feedback: 'positive' for a good experience or 'negative' for a "
1007                "bad experience. Use 'positive' when the user expresses satisfaction, "
1008                "praise, or a success story. Use 'negative' when the user reports a problem, "
1009                "frustration, or failure."
1010            ),
1011        ),
1012    ],
1013    category: Annotated[
1014        FeedbackCategory,
1015        Field(
1016            description=(
1017                "Feedback category. "
1018                "For NEGATIVE feedback, use one of: "
1019                "'tool_failure' (a specific tool/integration broke), "
1020                "'missing_guidance' (Devin lacked instructions or context), "
1021                "'suspected_hallucination' (Devin fabricated information or made incorrect claims), "
1022                "'bad_approach' (Devin took a fundamentally wrong strategy), "
1023                "'excessive_iteration' (too many loops/retries before success), "
1024                "'poor_quality' (output quality below expectations), "
1025                "'other_concern'. "
1026                "For POSITIVE feedback, use one of: "
1027                "'great_results' (task completed with high quality), "
1028                "'exceeded_expectations' (went above and beyond), "
1029                "'fast_completion' (completed quickly and efficiently), "
1030                "'good_communication' (kept user well-informed), "
1031                "'other_positive_feedback'."
1032            ),
1033        ),
1034    ],
1035    task_description: Annotated[
1036        str,
1037        Field(
1038            description=(
1039                "Brief description of what the user asked Devin to do. "
1040                "This sets the context for the feedback."
1041            ),
1042        ),
1043    ],
1044    agent_session_url: Annotated[
1045        str,
1046        Field(
1047            description=(
1048                "Your agent session URL so the team can view the full context. "
1049                "Use the session URL from your system prompt."
1050            ),
1051        ),
1052    ],
1053    reporting_user: Annotated[
1054        str,
1055        Field(
1056            description=(
1057                "The person providing the feedback. Accepts an email address "
1058                "(e.g. 'aj@airbyte.io'), a GitHub handle prefixed with @ "
1059                "(e.g. '@aaronsteers'), or a Slack user ID (e.g. 'U05AKF1BCC9')."
1060            ),
1061        ),
1062    ],
1063    session_playbook: Annotated[
1064        str,
1065        Field(
1066            description=(
1067                "ID of the Devin playbook associated with the session (e.g. "
1068                "'devin_feedback_triage'), or 'none' when no playbook is associated. "
1069                "Required so feedback can identify whether playbook instructions may need updates."
1070            ),
1071        ),
1072    ],
1073    related_skill_name: Annotated[
1074        str | None,
1075        Field(
1076            default=None,
1077            description=(
1078                "Optional skill ID associated with the feedback (e.g. "
1079                "'delete-declarative-source-def') when a related skill may need updates "
1080                "or is suspected of having issues."
1081            ),
1082        ),
1083    ],
1084    expected_behavior: Annotated[
1085        str | None,
1086        Field(
1087            default=None,
1088            description=(
1089                "What should have happened. REQUIRED for negative feedback. "
1090                "Describe the expected outcome clearly."
1091            ),
1092        ),
1093    ],
1094    observed_behavior: Annotated[
1095        str | None,
1096        Field(
1097            default=None,
1098            description=(
1099                "What actually happened. REQUIRED for negative feedback. "
1100                "Describe the actual outcome, including any error messages or unexpected results."
1101            ),
1102        ),
1103    ],
1104    what_went_well: Annotated[
1105        str | None,
1106        Field(
1107            default=None,
1108            description=(
1109                "What specifically was good about the experience. REQUIRED for positive feedback. "
1110                "Be specific about what Devin did well."
1111            ),
1112        ),
1113    ],
1114    severity: Annotated[
1115        Literal["low", "medium", "high", "critical"] | None,
1116        Field(
1117            default=None,
1118            description=(
1119                "Severity of the issue. Recommended for negative feedback. "
1120                "'low' = minor inconvenience, 'medium' = notable impact, "
1121                "'high' = significant blocker, 'critical' = complete failure."
1122            ),
1123        ),
1124    ],
1125    steps_to_reproduce: Annotated[
1126        str | None,
1127        Field(
1128            default=None,
1129            description=(
1130                "Optional steps to reproduce the issue. Helpful for negative feedback "
1131                "to enable the team to investigate."
1132            ),
1133        ),
1134    ],
1135    session_to_evaluate: Annotated[
1136        str | None,
1137        Field(
1138            default=None,
1139            description=(
1140                "Optional Devin session URL to evaluate/triage. Use this when reporting "
1141                "feedback about a *different* session (not your own). If omitted, "
1142                "agent_session_url is used as the session to triage (i.e., the reporter "
1143                "is reporting on itself)."
1144            ),
1145        ),
1146    ],
1147) -> SessionFeedbackResponse:
1148    """Report structured feedback about a Devin session experience via Slack.
1149
1150    Posts a formatted feedback message to the #hydra-feedback Slack channel,
1151    tagging the reporting user and @AJ Steers. The message includes a clickable
1152    button for the Devin session link. For negative feedback, a triage workflow
1153    is automatically dispatched to launch a Devin session with v3 analyze mode
1154    that can inspect the original session's full conversation history.
1155
1156    IMPORTANT: This feedback will be logged publicly in Slack. Inform the user
1157    that their feedback is visible to the team and they may be contacted for
1158    additional details.
1159
1160    Use this tool when a user explicitly asks to report a positive or negative
1161    experience with their Devin session. Before calling this tool, let the user
1162    know:
1163    - Their feedback will be posted publicly in the #hydra-feedback Slack channel
1164    - They may be contacted by the team for more details
1165    - Both the reporting user and @AJ Steers will be tagged in the message
1166    - For negative feedback, a triage session will be automatically launched to inspect the reported session
1167
1168    The Slack message is sent by a GitHub Actions workflow so that Slack
1169    credentials are never exposed to the calling agent.
1170    """
1171    # Validate category matches feedback type.
1172    cat = FeedbackCategory(category)
1173    is_negative_feedback = feedback_type == "negative"
1174    id_validation_error = _validate_playbook_id(session_playbook) or _validate_skill_id(
1175        related_skill_name
1176    )
1177    if id_validation_error:
1178        return SessionFeedbackResponse(
1179            success=False,
1180            message=id_validation_error,
1181        )
1182
1183    if cat.is_negative() != is_negative_feedback:
1184        expected_kind = "negative" if is_negative_feedback else "positive"
1185        valid = [
1186            c.value for c in FeedbackCategory if c.is_negative() == is_negative_feedback
1187        ]
1188        return SessionFeedbackResponse(
1189            success=False,
1190            message=(
1191                f"Invalid category '{category}' for {feedback_type} feedback. "
1192                f"Valid {expected_kind} categories: {', '.join(valid)}."
1193            ),
1194        )
1195
1196    # Validate required fields based on feedback type.
1197    if feedback_type == "negative":
1198        validation_error = _validate_negative_fields(
1199            expected_behavior=expected_behavior,
1200            observed_behavior=observed_behavior,
1201        )
1202    else:
1203        validation_error = _validate_positive_fields(
1204            what_went_well=what_went_well,
1205        )
1206
1207    if validation_error:
1208        return SessionFeedbackResponse(
1209            success=False,
1210            message=validation_error,
1211        )
1212
1213    message_body = _build_feedback_body(
1214        feedback_type=feedback_type,
1215        category=category,
1216        task_description=task_description,
1217        session_playbook=session_playbook,
1218        related_skill_name=related_skill_name,
1219        expected_behavior=expected_behavior,
1220        observed_behavior=observed_behavior,
1221        what_went_well=what_went_well,
1222        severity=severity,
1223        steps_to_reproduce=steps_to_reproduce,
1224    )
1225
1226    # For negative feedback, dispatch triage workflow (which also posts to Slack
1227    # via the HITL reusable workflow — single message with triage button).
1228    # For positive feedback, dispatch HITL directly (no triage needed).
1229    if is_negative_feedback:
1230        triage_session_url = session_to_evaluate or agent_session_url
1231        # _dispatch_triage_workflow catches exceptions internally and returns None
1232        triage_result = _dispatch_triage_workflow(
1233            session_url=triage_session_url,
1234            feedback_context=message_body,
1235            reporting_user=reporting_user,
1236            session_playbook=session_playbook,
1237            related_skill_name=related_skill_name,
1238            cc_persons=_AJ_STEERS_IDENTIFIER,
1239            header_emoji=_feedback_emoji(feedback_type),
1240            header_label=_feedback_label(feedback_type),
1241        )
1242        if triage_result is not None:
1243            view_url = triage_result.run_url or triage_result.workflow_url
1244            return SessionFeedbackResponse(
1245                success=True,
1246                message=(
1247                    "Feedback submitted. Auto-triage workflow launched. "
1248                    "A Slack notification will be posted to #hydra-feedback "
1249                    "once the triage session starts. "
1250                    f"View workflow progress at: {view_url}"
1251                ),
1252                workflow_url=triage_result.workflow_url,
1253                run_id=triage_result.run_id,
1254                run_url=triage_result.run_url,
1255                triage_run_url=view_url,
1256            )
1257        # Triage dispatch failed — fall back to direct HITL notification
1258        # so negative feedback is still recorded in Slack.
1259        logger.warning(
1260            "Triage workflow dispatch failed; falling back to direct HITL dispatch."
1261        )
1262
1263    # Positive feedback (or negative feedback fallback): dispatch HITL directly
1264    result = dispatch_escalation(
1265        target_person=reporting_user,
1266        message=message_body,
1267        agent_session_url=agent_session_url,
1268        cc=[_AJ_STEERS_IDENTIFIER],
1269        channel_override=_FEEDBACK_CHANNEL,
1270        header_emoji=_feedback_emoji(feedback_type),
1271        header_label=_feedback_label(feedback_type),
1272    )
1273
1274    view_url = result.run_url or result.workflow_url
1275    return SessionFeedbackResponse(
1276        success=True,
1277        message=(
1278            f"Feedback submitted and posted to #hydra-feedback. "
1279            f"The reporting user and @AJ Steers have been tagged. "
1280            f"View progress at: {view_url}"
1281        ),
1282        workflow_url=result.workflow_url,
1283        run_id=result.run_id,
1284        run_url=result.run_url,
1285    )
1286
1287
1288_FOLLOWUP_HEADER = "🤖 *Automated Triage Update*"
1289
1290_FOLLOWUP_FOOTER_TEMPLATE = (
1291    "_ℹ️ This thread is not monitored by Devin. "
1292    "Replies here will not be seen by any agent. "
1293    "For follow-up, use the <{agent_session_url}|linked session> or create a new task._"
1294)
1295
1296
1297def _wrap_followup_message(message: str, *, agent_session_url: str) -> str:
1298    """Wrap a follow-up message with session link and non-interactive disclaimer."""
1299    footer = _FOLLOWUP_FOOTER_TEMPLATE.format(agent_session_url=agent_session_url)
1300    return f"{_FOLLOWUP_HEADER}\n\n{message}\n\n{footer}"
1301
1302
1303class SessionFeedbackFollowupResponse(BaseModel):
1304    """Response from the session feedback follow-up tool."""
1305
1306    success: bool = Field(description="Whether the follow-up was posted successfully")
1307    message: str = Field(description="Human-readable status message")
1308    reply_ts: str | None = Field(
1309        default=None,
1310        description="Timestamp of the posted reply (Slack ts format)",
1311    )
1312
1313
1314@mcp_tool(
1315    read_only=False,
1316    idempotent=False,
1317    open_world=True,
1318)
1319def devin_session_feedback_followup(
1320    thread_url: Annotated[
1321        str,
1322        Field(
1323            description=(
1324                "Slack thread URL from the original feedback post in #hydra-feedback. "
1325                "This is the thread where follow-up context will be appended. "
1326                "Example: https://airbytehq-team.slack.com/archives/C0ACUHRP6B1/p1773062711122019"
1327            ),
1328        ),
1329    ],
1330    message: Annotated[
1331        str,
1332        Field(
1333            description=(
1334                "Follow-up message text in Slack mrkdwn format. "
1335                "Typically a triage report or additional context about the "
1336                "feedback being investigated. "
1337                "Supports *bold*, _italic_, `code`, ```code blocks```, "
1338                "> blockquotes, and <url|label> links."
1339            ),
1340        ),
1341    ],
1342    agent_session_url: Annotated[
1343        str,
1344        Field(
1345            description=(
1346                "Your agent session URL for audit trail. "
1347                "Use the session URL from your system prompt."
1348            ),
1349        ),
1350    ],
1351) -> SessionFeedbackFollowupResponse:
1352    """Post a follow-up to an existing feedback thread in #hydra-feedback.
1353
1354    This is the "second call" in the feedback workflow: after
1355    `devin_session_feedback` creates the initial report, this tool appends
1356    triage findings or additional context as a threaded reply.
1357
1358    Each reply is wrapped with a disclaimer clarifying that the thread is
1359    non-interactive and not monitored by any agent.
1360
1361    Workspace validation ensures only URLs from the expected Slack
1362    workspace are accepted.
1363    """
1364    try:
1365        channel_id, thread_ts = parse_slack_thread_url(thread_url)
1366    except SlackURLParseError as exc:
1367        return SessionFeedbackFollowupResponse(
1368            success=False,
1369            message=str(exc),
1370        )
1371
1372    wrapped_message = _wrap_followup_message(
1373        message, agent_session_url=agent_session_url
1374    )
1375
1376    try:
1377        result = post_thread_reply(
1378            channel_id=channel_id,
1379            thread_ts=thread_ts,
1380            message=wrapped_message,
1381        )
1382        reply_ts = result.ts
1383    except SlackAPIError as exc:
1384        return SessionFeedbackFollowupResponse(
1385            success=False,
1386            message=f"Slack API error: {exc}",
1387        )
1388
1389    logger.info(
1390        "Feedback follow-up posted: channel=%s thread_ts=%s agent=%s",
1391        channel_id,
1392        thread_ts,
1393        agent_session_url,
1394    )
1395    return SessionFeedbackFollowupResponse(
1396        success=True,
1397        message=f"Follow-up posted to feedback thread in channel {channel_id}.",
1398        reply_ts=reply_ts,
1399    )
1400
1401
1402class DevinSessionNameResponse(BaseModel):
1403    """Response from the Devin session naming tool."""
1404
1405    session_id: str = Field(description="The input session ID")
1406    scheme_version: str = Field(description="The naming scheme version identifier")
1407    name: str = Field(
1408        description="The generated human-friendly session name in Title Case"
1409    )
1410    full_name: str = Field(
1411        description="The contextual full name including 'Devin' suffix (e.g. 'Silly Fred Devin')"
1412    )
1413
1414
1415@mcp_tool(
1416    read_only=True,
1417    idempotent=True,
1418)
1419def get_devin_session_name(
1420    session_id: Annotated[
1421        str,
1422        "The Devin session identifier or session URL. Accepts a raw session "
1423        "ID (e.g. 'b2a641e838214f91b50d0f88940ac119') or a full session URL "
1424        "(e.g. 'https://app.devin.ai/sessions/b2a641e8...'). The ID is "
1425        "extracted automatically from URLs. The same ID always produces "
1426        "the same name — this is a deterministic lookup, not a creation.",
1427    ],
1428) -> DevinSessionNameResponse:
1429    """Look up the deterministic friendly name for a Devin session.
1430
1431    Uses the silly-buddy naming scheme to generate a Title Case two-word
1432    name (e.g. "Smelly Fred") from the session ID. The output is immutable
1433    and idempotent — the same session ID always yields the same name.
1434
1435    If a full URL is provided instead of a bare ID, the session ID is
1436    extracted from the URL automatically.
1437    """
1438    resolved_id = extract_session_id(session_id)
1439    scheme = NamingScheme.SILLY_BUDDY
1440    name = generate_friendly_name(resolved_id, scheme)
1441    full_name = f"{name} Devin"
1442    return DevinSessionNameResponse(
1443        session_id=resolved_id,
1444        scheme_version="v1",
1445        name=name,
1446        full_name=full_name,
1447    )
1448
1449
1450def register_devin_ops_tools(app: FastMCP) -> None:
1451    """Register devin_ops tools with the FastMCP app."""
1452    register_mcp_tools(app, mcp_module=__name__)