From ca4d9ce5fade1de80a661353ff39635f841f4c56 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 1 Jul 2026 17:43:35 +0000 Subject: [PATCH] feat(integrations): add Azure DevOps MCP catalog entry Add a first-class Azure DevOps integration to the MCP marketplace catalog so Agent Canvas users can connect their Azure DevOps organization directly from the MCP UI. Surfaces the official Microsoft Azure DevOps MCP Server (https://github.com/microsoft/azure-devops-mcp) with two connection options: - Remote (recommended, public preview): Azure DevOps-hosted streamable HTTP endpoint at https://mcp.dev.azure.com/{organization}, authenticated with Microsoft Entra ID (OAuth). The URL is editable so users fill in their organization name. - Local (PAT): stdio via , with the organization passed as a CLI argument and the Personal Access Token supplied through the documented PERSONAL_ACCESS_TOKEN environment variable (base64-encoded). Adds the Azure DevOps logo asset under integrations/icons/ and regenerates the catalog index. Closes OpenHands/agent-canvas#929 Co-authored-by: openhands --- integrations/catalog-index.js | 136 +++++++++++++------------ integrations/catalog/azure-devops.json | 86 ++++++++++++++++ integrations/icons/azure-devops.svg | 1 + tests/test_catalogs.py | 46 +++++++++ 4 files changed, 202 insertions(+), 67 deletions(-) create mode 100644 integrations/catalog/azure-devops.json create mode 100644 integrations/icons/azure-devops.svg diff --git a/integrations/catalog-index.js b/integrations/catalog-index.js index 0860fcc9..9d307654 100644 --- a/integrations/catalog-index.js +++ b/integrations/catalog-index.js @@ -7,73 +7,74 @@ import entry1 from "./catalog/slack.json" with { type: "json" }; import entry2 from "./catalog/tavily.json" with { type: "json" }; import entry3 from "./catalog/linear.json" with { type: "json" }; import entry4 from "./catalog/notion.json" with { type: "json" }; -import entry5 from "./catalog/ordinal.json" with { type: "json" }; -import entry6 from "./catalog/elevenlabs.json" with { type: "json" }; -import entry7 from "./catalog/bitbucket.json" with { type: "json" }; -import entry8 from "./catalog/xero.json" with { type: "json" }; -import entry9 from "./catalog/quickbooks.json" with { type: "json" }; -import entry10 from "./catalog/okta.json" with { type: "json" }; -import entry11 from "./catalog/netlify.json" with { type: "json" }; -import entry12 from "./catalog/vercel.json" with { type: "json" }; -import entry13 from "./catalog/supabase.json" with { type: "json" }; -import entry14 from "./catalog/posthog.json" with { type: "json" }; -import entry15 from "./catalog/sentry.json" with { type: "json" }; -import entry16 from "./catalog/datadog.json" with { type: "json" }; -import entry17 from "./catalog/canva.json" with { type: "json" }; -import entry18 from "./catalog/miro.json" with { type: "json" }; -import entry19 from "./catalog/webflow.json" with { type: "json" }; -import entry20 from "./catalog/zoom.json" with { type: "json" }; -import entry21 from "./catalog/discord.json" with { type: "json" }; -import entry22 from "./catalog/stripe.json" with { type: "json" }; -import entry23 from "./catalog/intercom.json" with { type: "json" }; -import entry24 from "./catalog/hubspot.json" with { type: "json" }; -import entry25 from "./catalog/salesforce.json" with { type: "json" }; -import entry26 from "./catalog/sharepoint.json" with { type: "json" }; -import entry27 from "./catalog/onedrive.json" with { type: "json" }; -import entry28 from "./catalog/microsoft-teams.json" with { type: "json" }; -import entry29 from "./catalog/microsoft-outlook.json" with { type: "json" }; -import entry30 from "./catalog/box.json" with { type: "json" }; -import entry31 from "./catalog/airtable.json" with { type: "json" }; -import entry32 from "./catalog/monday.json" with { type: "json" }; -import entry33 from "./catalog/trello.json" with { type: "json" }; -import entry34 from "./catalog/asana.json" with { type: "json" }; -import entry35 from "./catalog/confluence.json" with { type: "json" }; -import entry36 from "./catalog/jira.json" with { type: "json" }; -import entry37 from "./catalog/google-calendar.json" with { type: "json" }; -import entry38 from "./catalog/gmail.json" with { type: "json" }; -import entry39 from "./catalog/google-sheets.json" with { type: "json" }; -import entry40 from "./catalog/google-drive.json" with { type: "json" }; -import entry41 from "./catalog/figma.json" with { type: "json" }; -import entry42 from "./catalog/google-docs.json" with { type: "json" }; -import entry43 from "./catalog/apify.json" with { type: "json" }; -import entry44 from "./catalog/atlassian.json" with { type: "json" }; -import entry45 from "./catalog/brave-search.json" with { type: "json" }; -import entry46 from "./catalog/browser-mcp.json" with { type: "json" }; -import entry47 from "./catalog/clickhouse.json" with { type: "json" }; -import entry48 from "./catalog/cloudflare-bindings.json" with { type: "json" }; -import entry49 from "./catalog/cloudflare-browser-rendering.json" with { type: "json" }; -import entry50 from "./catalog/cloudflare-builds.json" with { type: "json" }; -import entry51 from "./catalog/cloudflare-docs.json" with { type: "json" }; -import entry52 from "./catalog/cloudflare-observability.json" with { type: "json" }; -import entry53 from "./catalog/deepwiki.json" with { type: "json" }; -import entry54 from "./catalog/everything.json" with { type: "json" }; -import entry55 from "./catalog/exa.json" with { type: "json" }; -import entry56 from "./catalog/fetch.json" with { type: "json" }; -import entry57 from "./catalog/filesystem.json" with { type: "json" }; -import entry58 from "./catalog/firecrawl.json" with { type: "json" }; -import entry59 from "./catalog/git.json" with { type: "json" }; -import entry60 from "./catalog/huggingface.json" with { type: "json" }; -import entry61 from "./catalog/kagi.json" with { type: "json" }; -import entry62 from "./catalog/memory.json" with { type: "json" }; -import entry63 from "./catalog/mongodb.json" with { type: "json" }; -import entry64 from "./catalog/neon.json" with { type: "json" }; -import entry65 from "./catalog/obsidian.json" with { type: "json" }; -import entry66 from "./catalog/paypal.json" with { type: "json" }; -import entry67 from "./catalog/playwright.json" with { type: "json" }; -import entry68 from "./catalog/redis.json" with { type: "json" }; -import entry69 from "./catalog/resend.json" with { type: "json" }; -import entry70 from "./catalog/sequential-thinking.json" with { type: "json" }; -import entry71 from "./catalog/time.json" with { type: "json" }; +import entry5 from "./catalog/azure-devops.json" with { type: "json" }; +import entry6 from "./catalog/ordinal.json" with { type: "json" }; +import entry7 from "./catalog/elevenlabs.json" with { type: "json" }; +import entry8 from "./catalog/bitbucket.json" with { type: "json" }; +import entry9 from "./catalog/xero.json" with { type: "json" }; +import entry10 from "./catalog/quickbooks.json" with { type: "json" }; +import entry11 from "./catalog/okta.json" with { type: "json" }; +import entry12 from "./catalog/netlify.json" with { type: "json" }; +import entry13 from "./catalog/vercel.json" with { type: "json" }; +import entry14 from "./catalog/supabase.json" with { type: "json" }; +import entry15 from "./catalog/posthog.json" with { type: "json" }; +import entry16 from "./catalog/sentry.json" with { type: "json" }; +import entry17 from "./catalog/datadog.json" with { type: "json" }; +import entry18 from "./catalog/canva.json" with { type: "json" }; +import entry19 from "./catalog/miro.json" with { type: "json" }; +import entry20 from "./catalog/webflow.json" with { type: "json" }; +import entry21 from "./catalog/zoom.json" with { type: "json" }; +import entry22 from "./catalog/discord.json" with { type: "json" }; +import entry23 from "./catalog/stripe.json" with { type: "json" }; +import entry24 from "./catalog/intercom.json" with { type: "json" }; +import entry25 from "./catalog/hubspot.json" with { type: "json" }; +import entry26 from "./catalog/salesforce.json" with { type: "json" }; +import entry27 from "./catalog/sharepoint.json" with { type: "json" }; +import entry28 from "./catalog/onedrive.json" with { type: "json" }; +import entry29 from "./catalog/microsoft-teams.json" with { type: "json" }; +import entry30 from "./catalog/microsoft-outlook.json" with { type: "json" }; +import entry31 from "./catalog/box.json" with { type: "json" }; +import entry32 from "./catalog/airtable.json" with { type: "json" }; +import entry33 from "./catalog/monday.json" with { type: "json" }; +import entry34 from "./catalog/trello.json" with { type: "json" }; +import entry35 from "./catalog/asana.json" with { type: "json" }; +import entry36 from "./catalog/confluence.json" with { type: "json" }; +import entry37 from "./catalog/jira.json" with { type: "json" }; +import entry38 from "./catalog/google-calendar.json" with { type: "json" }; +import entry39 from "./catalog/gmail.json" with { type: "json" }; +import entry40 from "./catalog/google-sheets.json" with { type: "json" }; +import entry41 from "./catalog/google-drive.json" with { type: "json" }; +import entry42 from "./catalog/figma.json" with { type: "json" }; +import entry43 from "./catalog/google-docs.json" with { type: "json" }; +import entry44 from "./catalog/apify.json" with { type: "json" }; +import entry45 from "./catalog/atlassian.json" with { type: "json" }; +import entry46 from "./catalog/brave-search.json" with { type: "json" }; +import entry47 from "./catalog/browser-mcp.json" with { type: "json" }; +import entry48 from "./catalog/clickhouse.json" with { type: "json" }; +import entry49 from "./catalog/cloudflare-bindings.json" with { type: "json" }; +import entry50 from "./catalog/cloudflare-browser-rendering.json" with { type: "json" }; +import entry51 from "./catalog/cloudflare-builds.json" with { type: "json" }; +import entry52 from "./catalog/cloudflare-docs.json" with { type: "json" }; +import entry53 from "./catalog/cloudflare-observability.json" with { type: "json" }; +import entry54 from "./catalog/deepwiki.json" with { type: "json" }; +import entry55 from "./catalog/everything.json" with { type: "json" }; +import entry56 from "./catalog/exa.json" with { type: "json" }; +import entry57 from "./catalog/fetch.json" with { type: "json" }; +import entry58 from "./catalog/filesystem.json" with { type: "json" }; +import entry59 from "./catalog/firecrawl.json" with { type: "json" }; +import entry60 from "./catalog/git.json" with { type: "json" }; +import entry61 from "./catalog/huggingface.json" with { type: "json" }; +import entry62 from "./catalog/kagi.json" with { type: "json" }; +import entry63 from "./catalog/memory.json" with { type: "json" }; +import entry64 from "./catalog/mongodb.json" with { type: "json" }; +import entry65 from "./catalog/neon.json" with { type: "json" }; +import entry66 from "./catalog/obsidian.json" with { type: "json" }; +import entry67 from "./catalog/paypal.json" with { type: "json" }; +import entry68 from "./catalog/playwright.json" with { type: "json" }; +import entry69 from "./catalog/redis.json" with { type: "json" }; +import entry70 from "./catalog/resend.json" with { type: "json" }; +import entry71 from "./catalog/sequential-thinking.json" with { type: "json" }; +import entry72 from "./catalog/time.json" with { type: "json" }; export const INTEGRATION_CATALOG_ENTRIES = [ entry0, @@ -148,4 +149,5 @@ export const INTEGRATION_CATALOG_ENTRIES = [ entry69, entry70, entry71, + entry72, ]; diff --git a/integrations/catalog/azure-devops.json b/integrations/catalog/azure-devops.json new file mode 100644 index 00000000..1aafbb72 --- /dev/null +++ b/integrations/catalog/azure-devops.json @@ -0,0 +1,86 @@ +{ + "id": "azure-devops", + "name": "Azure DevOps", + "description": "Manage work items, repos, pull requests, pipelines, wikis, and test plans via the official Microsoft Azure DevOps MCP Server.", + "categories": [ + "Engineering", + "Project management" + ], + "appUrl": "https://azure.microsoft.com/en-us/products/devops", + "docsUrl": "https://github.com/microsoft/azure-devops-mcp", + "notes": "Azure DevOps is already a recognized git/auth provider in OpenHands. This entry surfaces the official Microsoft Azure DevOps MCP Server so agents can act on work items, repos, pipelines, wikis, and test plans. The remote server (recommended, public preview) is Azure DevOps-hosted and authenticates with Microsoft Entra ID (OAuth); the local stdio server authenticates with an Azure DevOps Personal Access Token.", + "iconBg": "#0078D4", + "logoUrl": "https://cdn.jsdelivr.net/gh/OpenHands/extensions/integrations/icons/azure-devops.svg", + "keywords": [ + "ado", + "devops", + "work items", + "pipelines", + "repos", + "boards", + "azure" + ], + "popularityRank": 80, + "installHint": "Prefer the Remote (OAuth) connection: it is hosted by Azure DevOps and needs no local install. For the Local (PAT) path, the organization name is passed as a CLI argument and the Personal Access Token is supplied via the PERSONAL_ACCESS_TOKEN environment variable as a base64-encoded \":\" value.", + "connectionOptions": [ + { + "id": "remote", + "provider": "mcp", + "auth": { + "strategy": "oauth2", + "oauth": { + "authorizationUrl": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + "tokenUrl": "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "scopes": [ + "499b84ac-1321-427f-aa17-267ca697acd9/.default" + ], + "pkce": true, + "clientAuthentication": "none" + } + }, + "transport": { + "kind": "shttp", + "url": "https://mcp.dev.azure.com/{organization}", + "urlEditable": true + } + }, + { + "id": "pat", + "provider": "mcp", + "auth": { + "strategy": "api_key" + }, + "transport": { + "kind": "stdio", + "serverName": "azure-devops", + "command": "npx", + "args": [ + "-y", + "@azure-devops/mcp", + "--authentication", + "pat" + ], + "argFields": [ + { + "key": "organization", + "label": "Organization name", + "placeholder": "contoso", + "required": true, + "helperText": "Your Azure DevOps organization name (e.g. 'contoso' for https://dev.azure.com/contoso). Passed as the first CLI argument." + } + ], + "envFields": [ + { + "key": "PERSONAL_ACCESS_TOKEN", + "label": "Personal Access Token", + "type": "password", + "placeholder": "base64-encoded :PAT", + "required": true, + "helperText": "Base64-encode the value \":\" (for example `echo -n \"user:YOUR_PAT\" | base64`) and store it here. Create a PAT at User Settings → Personal Access Tokens in Azure DevOps. See the [local setup docs](https://github.com/microsoft/azure-devops-mcp).", + "helperLink": "https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/integrations/icons/azure-devops.svg b/integrations/icons/azure-devops.svg new file mode 100644 index 00000000..d345c49b --- /dev/null +++ b/integrations/icons/azure-devops.svg @@ -0,0 +1 @@ +Azure DevOps \ No newline at end of file diff --git a/tests/test_catalogs.py b/tests/test_catalogs.py index bb0a35cf..59e129d5 100644 --- a/tests/test_catalogs.py +++ b/tests/test_catalogs.py @@ -156,3 +156,49 @@ def test_http_connectors_have_openapi_url(): if not url or not url.startswith("https://"): offenders.append(f"{entry['id']}.{option['id']}") assert not offenders, f"HTTP options missing openApiUrl: {offenders}" + + +def _entry_by_id(entry_id: str) -> dict: + for entry in load_catalog_entries("integrations/catalog"): + if entry["id"] == entry_id: + return entry + raise AssertionError(f"catalog entry '{entry_id}' not found") + + +def test_azure_devops_entry_contract(): + """The Azure DevOps MCP integration (issue #929) surfaces the official + Microsoft Azure DevOps MCP Server with a hosted remote (OAuth) connection + and a local PAT-based stdio fallback, plus a logo asset.""" + entry = _entry_by_id("azure-devops") + assert entry["name"] == "Azure DevOps" + assert entry["logoUrl"].startswith("https://") + assert entry["iconBg"] + + options = {option["id"]: option for option in entry["connectionOptions"]} + assert {"remote", "pat"} <= set(options) + + remote = options["remote"] + assert remote["provider"] == "mcp" + assert remote["auth"]["strategy"] == "oauth2" + assert remote["transport"]["kind"] == "shttp" + assert remote["transport"]["url"] == "https://mcp.dev.azure.com/{organization}" + # The organization segment is account-specific, so the URL must be editable. + assert remote["transport"].get("urlEditable") is True + + pat = options["pat"] + assert pat["provider"] == "mcp" + assert pat["auth"]["strategy"] == "api_key" + assert pat["transport"]["kind"] == "stdio" + assert pat["transport"]["command"] == "npx" + assert "@azure-devops/mcp" in pat["transport"]["args"] + assert "--authentication" in pat["transport"]["args"] + assert "pat" in pat["transport"]["args"] + # The PAT is supplied via the documented PERSONAL_ACCESS_TOKEN env var. + env_fields = {field["key"]: field for field in pat["transport"]["envFields"]} + assert "PERSONAL_ACCESS_TOKEN" in env_fields + assert env_fields["PERSONAL_ACCESS_TOKEN"]["type"] == "password" + assert env_fields["PERSONAL_ACCESS_TOKEN"].get("required") is True + # The organization name is passed as a CLI positional argument. + arg_fields = {field["key"]: field for field in pat["transport"]["argFields"]} + assert "organization" in arg_fields + assert arg_fields["organization"].get("required") is True