diff --git a/integrations/catalog-index.js b/integrations/catalog-index.js index 0f56160c..06976ec6 100644 --- a/integrations/catalog-index.js +++ b/integrations/catalog-index.js @@ -77,6 +77,7 @@ import entry71 from "./catalog/resend.json" with { type: "json" }; import entry72 from "./catalog/sequential-thinking.json" with { type: "json" }; import entry73 from "./catalog/superhuman-mail.json" with { type: "json" }; import entry74 from "./catalog/time.json" with { type: "json" }; +import entry75 from "./catalog/xquik.json" with { type: "json" }; export const INTEGRATION_CATALOG_ENTRIES = [ entry0, @@ -154,4 +155,5 @@ export const INTEGRATION_CATALOG_ENTRIES = [ entry72, entry73, entry74, + entry75, ]; diff --git a/integrations/catalog/xquik.json b/integrations/catalog/xquik.json new file mode 100644 index 00000000..f634bbc4 --- /dev/null +++ b/integrations/catalog/xquik.json @@ -0,0 +1,67 @@ +{ + "id": "xquik", + "name": "Xquik", + "description": "Search X data, monitor activity, and manage X workflows from your agent.", + "categories": [ + "Social media", + "Automation" + ], + "appUrl": "https://xquik.com", + "docsUrl": "https://docs.xquik.com/mcp/overview", + "notes": "OAuth 2.1 is preferred. API keys are a client-specific fallback. Xquik is an independent third-party service. Not affiliated with X Corp. \"Twitter\" and \"X\" are trademarks of X Corp.", + "iconBg": "#5C3327", + "logoUrl": "https://xquik.com/icons/mcp/xquik.png", + "keywords": [ + "x", + "twitter", + "social", + "tweets", + "monitoring" + ], + "installHint": "Prefer OAuth 2.1. Use an Xquik API key only when your client provides secure bearer-token storage.", + "connectionOptions": [ + { + "id": "oauth", + "provider": "mcp", + "transport": { + "kind": "shttp", + "url": "https://xquik.com/mcp" + }, + "auth": { + "strategy": "oauth2", + "oauth": { + "authorizationUrl": "https://xquik.com/api/oauth/authorize", + "tokenUrl": "https://xquik.com/api/oauth/token", + "registrationUrl": "https://xquik.com/api/oauth/register", + "scopes": [ + "mcp:tools" + ], + "pkce": true, + "clientAuthentication": "none", + "additionalAuthorizationParams": { + "resource": "https://xquik.com/mcp" + }, + "additionalTokenParams": { + "resource": "https://xquik.com/mcp" + } + } + } + }, + { + "id": "api-key", + "provider": "mcp", + "transport": { + "kind": "shttp", + "url": "https://xquik.com/mcp" + }, + "auth": { + "strategy": "bearer", + "credentialLabel": "Xquik API key", + "credentialPlaceholder": "Paste your Xquik API key", + "credentialHelp": "Create an API key at https://dashboard.xquik.com/en/account?tab=api-keys. It is sent as Authorization: Bearer .", + "credentialSecretName": "XQUIK_API_KEY", + "saveCredentialAsSecretByDefault": true + } + } + ] +} diff --git a/scripts/build-integration-catalog.mjs b/scripts/build-integration-catalog.mjs index 6dd42789..f40f7b34 100644 --- a/scripts/build-integration-catalog.mjs +++ b/scripts/build-integration-catalog.mjs @@ -16,6 +16,17 @@ const integrations = await Promise.all( })), ); +const fileByIntegrationId = new Map(); +for (const { entry, file } of integrations) { + const existingFile = fileByIntegrationId.get(entry.id); + if (existingFile !== undefined) { + throw new Error( + `Duplicate integration id "${entry.id}" in ${existingFile} and ${file}.`, + ); + } + fileByIntegrationId.set(entry.id, file); +} + integrations.sort((left, right) => { const rank = (right.entry.popularityRank ?? -1) - (left.entry.popularityRank ?? -1); return rank || left.entry.id.localeCompare(right.entry.id); diff --git a/tests/test_integration_catalog_in_sync.py b/tests/test_integration_catalog_in_sync.py index ce22f04f..3b586156 100644 --- a/tests/test_integration_catalog_in_sync.py +++ b/tests/test_integration_catalog_in_sync.py @@ -78,6 +78,34 @@ def test_generated_js_index_references_catalog_directory() -> None: assert "integration-catalog.json" not in body +def test_build_rejects_duplicate_integration_ids(tmp_path: Path) -> None: + catalog_dir = tmp_path / "integrations" / "catalog" + catalog_dir.mkdir(parents=True) + duplicate_entry = {"id": "duplicate"} + for file_name in ("first.json", "second.json"): + (catalog_dir / file_name).write_text(json.dumps(duplicate_entry)) + + script_uri = (ROOT / "scripts" / "build-integration-catalog.mjs").as_uri() + result = subprocess.run( + [ + "node", + "--input-type=module", + "-e", + f"process.chdir({json.dumps(str(tmp_path))}); await import({json.dumps(script_uri)});", + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert ( + 'Duplicate integration id "duplicate" in first.json and second.json.' + in result.stderr + ) + assert not (tmp_path / "integrations" / "catalog-index.js").exists() + + def test_python_snapshot_is_built_from_catalog_directory() -> None: assert openhands_extensions.INTEGRATION_CATALOG_SNAPSHOT == { "integrations": _catalog_entries()