@openhands/extensions 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.github/workflows/deprecation-check.yml +25 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/README.md +3 -2
  4. package/integrations/README.md +25 -0
  5. package/integrations/catalog/atlassian-rovo.json +44 -0
  6. package/integrations/catalog/atlassian.json +13 -0
  7. package/integrations/catalog/github.json +21 -1
  8. package/integrations/catalog/linear.json +1 -1
  9. package/integrations/catalog/microsoft-outlook.json +13 -0
  10. package/integrations/catalog/microsoft-teams.json +13 -0
  11. package/integrations/catalog/notion.json +13 -0
  12. package/integrations/catalog/onedrive.json +13 -0
  13. package/integrations/catalog/sharepoint.json +13 -0
  14. package/integrations/catalog/slack.json +16 -2
  15. package/integrations/catalog/vanta.json +55 -0
  16. package/integrations/catalog-index.js +71 -67
  17. package/integrations/catalog.schema.json +70 -3
  18. package/integrations/index.d.ts +49 -3
  19. package/marketplaces/openhands-extensions.json +9 -0
  20. package/package.json +1 -1
  21. package/pyproject.toml +6 -1
  22. package/python/openhands_extensions/__init__.py +38 -0
  23. package/python/openhands_extensions/_version.py +2 -2
  24. package/python/openhands_extensions/deprecation.py +159 -0
  25. package/python/openhands_extensions/integration_models.py +233 -0
  26. package/python/openhands_extensions/integrations.py +59 -4
  27. package/scripts/check_deprecations.py +488 -0
  28. package/skills/index.js +7 -1
  29. package/skills/iterate/README.md +4 -1
  30. package/skills/iterate/SKILL.md +22 -2
  31. package/skills/technical-writing/.plugin/plugin.json +15 -0
  32. package/skills/technical-writing/README.md +14 -0
  33. package/skills/technical-writing/SKILL.md +106 -0
  34. package/tests/test_catalog_schema.py +58 -0
  35. package/tests/test_catalogs.py +67 -0
  36. package/tests/test_deprecations.py +136 -0
  37. package/tests/test_integration_catalog_in_sync.py +118 -7
  38. package/tests/test_live_integration_smoke.py +366 -0
@@ -5,7 +5,7 @@
5
5
  "description": "Strict schema for integrations/catalog/*.json. Mirrors integrations/index.d.ts and encodes the cross-field connectability rules the catalog contract depends on.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
- "required": ["id", "name", "description", "connectionOptions"],
8
+ "required": ["id", "name", "description", "docsUrl", "connectionOptions"],
9
9
  "properties": {
10
10
  "id": { "type": "string", "minLength": 1 },
11
11
  "name": { "type": "string", "minLength": 1 },
@@ -39,7 +39,7 @@
39
39
  "marketplaceField": {
40
40
  "type": "object",
41
41
  "additionalProperties": false,
42
- "required": ["key", "label"],
42
+ "required": ["key", "label", "type", "required"],
43
43
  "properties": {
44
44
  "key": { "type": "string", "minLength": 1 },
45
45
  "label": { "type": "string", "minLength": 1 },
@@ -91,6 +91,72 @@
91
91
  }
92
92
  }
93
93
  },
94
+ "identityPath": {
95
+ "type": "string",
96
+ "pattern": "^[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)*$",
97
+ "minLength": 1
98
+ },
99
+ "identityMapping": {
100
+ "type": "object",
101
+ "additionalProperties": false,
102
+ "required": ["source", "externalPrincipalIdPath"],
103
+ "properties": {
104
+ "source": {
105
+ "enum": ["oauth_token_response", "access_token_claims", "identity_api"]
106
+ },
107
+ "endpoint": { "type": "string", "pattern": "^https://", "minLength": 8 },
108
+ "externalPrincipalIdPath": { "$ref": "#/$defs/identityPath" },
109
+ "externalTenantIdPath": { "$ref": "#/$defs/identityPath" },
110
+ "externalResourceIdPath": { "$ref": "#/$defs/identityPath" },
111
+ "resourceNamePath": { "$ref": "#/$defs/identityPath" },
112
+ "resourceUrlPath": { "$ref": "#/$defs/identityPath" }
113
+ },
114
+ "allOf": [
115
+ {
116
+ "if": { "properties": { "source": { "const": "identity_api" } } },
117
+ "then": { "required": ["endpoint"] },
118
+ "else": { "not": { "required": ["endpoint"] } }
119
+ }
120
+ ]
121
+ },
122
+ "resourceDiscovery": {
123
+ "type": "object",
124
+ "additionalProperties": false,
125
+ "required": ["endpoint", "externalResourceIdPath"],
126
+ "properties": {
127
+ "endpoint": { "type": "string", "pattern": "^https://", "minLength": 8 },
128
+ "itemsPath": { "$ref": "#/$defs/identityPath" },
129
+ "externalResourceIdPath": { "$ref": "#/$defs/identityPath" },
130
+ "resourceNamePath": { "$ref": "#/$defs/identityPath" },
131
+ "resourceUrlPath": { "$ref": "#/$defs/identityPath" }
132
+ }
133
+ },
134
+ "connectionModel": {
135
+ "type": "object",
136
+ "additionalProperties": false,
137
+ "required": [
138
+ "principalType",
139
+ "credentialScope",
140
+ "resourceType",
141
+ "resourceCardinality",
142
+ "selectionMode"
143
+ ],
144
+ "properties": {
145
+ "principalType": { "enum": ["user", "bot", "service_account", "application"] },
146
+ "credentialScope": { "enum": ["account", "tenant", "organization", "resource"] },
147
+ "resourceType": { "type": "string", "minLength": 1 },
148
+ "resourceCardinality": { "enum": ["one", "many"] },
149
+ "selectionMode": { "enum": ["automatic", "post_auth", "runtime"] },
150
+ "identityMapping": { "$ref": "#/$defs/identityMapping" },
151
+ "resourceDiscovery": { "$ref": "#/$defs/resourceDiscovery" }
152
+ },
153
+ "allOf": [
154
+ {
155
+ "if": { "properties": { "selectionMode": { "const": "post_auth" } } },
156
+ "then": { "required": ["resourceDiscovery"] }
157
+ }
158
+ ]
159
+ },
94
160
  "authConfig": {
95
161
  "type": "object",
96
162
  "additionalProperties": false,
@@ -186,7 +252,8 @@
186
252
  "provider": { "enum": ["mcp", "http"] },
187
253
  "transport": { "$ref": "#/$defs/transport" },
188
254
  "http": { "$ref": "#/$defs/httpConfig" },
189
- "auth": { "$ref": "#/$defs/authConfig" }
255
+ "auth": { "$ref": "#/$defs/authConfig" },
256
+ "connectionModel": { "$ref": "#/$defs/connectionModel" }
190
257
  },
191
258
  "allOf": [
192
259
  {
@@ -3,11 +3,11 @@ export type MarketplaceFieldType = "text" | "password";
3
3
  export interface MarketplaceField {
4
4
  key: string;
5
5
  label: string;
6
- type?: MarketplaceFieldType;
6
+ type: MarketplaceFieldType;
7
7
  placeholder?: string;
8
8
  helperText?: string;
9
9
  helperLink?: string;
10
- required?: boolean;
10
+ required: boolean;
11
11
  }
12
12
 
13
13
  export type IntegrationTransport =
@@ -95,12 +95,58 @@ export interface IntegrationHttpConfig {
95
95
  openApiUrl?: string;
96
96
  }
97
97
 
98
+ export type IntegrationPrincipalType =
99
+ | "user"
100
+ | "bot"
101
+ | "service_account"
102
+ | "application";
103
+
104
+ export type IntegrationCredentialScope =
105
+ | "account"
106
+ | "tenant"
107
+ | "organization"
108
+ | "resource";
109
+
110
+ export type IntegrationIdentitySource =
111
+ | "oauth_token_response"
112
+ | "access_token_claims"
113
+ | "identity_api";
114
+
115
+ export interface IntegrationIdentityMapping {
116
+ source: IntegrationIdentitySource;
117
+ endpoint?: string;
118
+ externalPrincipalIdPath: string;
119
+ externalTenantIdPath?: string;
120
+ externalResourceIdPath?: string;
121
+ resourceNamePath?: string;
122
+ resourceUrlPath?: string;
123
+ }
124
+
125
+ export interface IntegrationResourceDiscovery {
126
+ endpoint: string;
127
+ itemsPath?: string;
128
+ externalResourceIdPath: string;
129
+ resourceNamePath?: string;
130
+ resourceUrlPath?: string;
131
+ }
132
+
133
+ export interface IntegrationConnectionModel {
134
+ principalType: IntegrationPrincipalType;
135
+ credentialScope: IntegrationCredentialScope;
136
+ resourceType: string;
137
+ resourceCardinality: "one" | "many";
138
+ selectionMode: "automatic" | "post_auth" | "runtime";
139
+ identityMapping?: IntegrationIdentityMapping;
140
+ resourceDiscovery?: IntegrationResourceDiscovery;
141
+ }
142
+
98
143
  export interface IntegrationConnectionOption {
99
144
  id: "oauth" | "api" | "none" | string;
100
145
  provider: IntegrationProvider;
101
146
  transport?: IntegrationTransport;
102
147
  http?: IntegrationHttpConfig;
103
148
  auth: IntegrationAuthConfig;
149
+ connectionModel?: IntegrationConnectionModel;
104
150
  }
105
151
 
106
152
 
@@ -110,7 +156,7 @@ export interface IntegrationCatalogEntry {
110
156
  description: string;
111
157
  categories?: string[];
112
158
  appUrl?: string;
113
- docsUrl?: string;
159
+ docsUrl: string;
114
160
  notes?: string;
115
161
  iconBg?: string;
116
162
  iconColor?: string;
@@ -729,6 +729,15 @@
729
729
  "automation"
730
730
  ]
731
731
  },
732
+ {
733
+ "name": "technical-writing",
734
+ "source": "./skills/technical-writing",
735
+ "description": "Write and revise technical explanations in flowing, direct, conversational prose that stays concise without becoming fragmented or vague.",
736
+ "category": "productivity",
737
+ "keywords": [
738
+ "technical-writing"
739
+ ]
740
+ },
732
741
  {
733
742
  "name": "plain-english-content",
734
743
  "source": "./skills/plain-english-content",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openhands/extensions",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Public OpenHands extension catalogs for skills, plugins, integrations, and automation templates.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -1,8 +1,13 @@
1
1
  [project]
2
2
  name = "openhands-extensions"
3
- version = "0.9.0"
3
+ version = "0.11.0"
4
4
  description = "OpenHands extensions, plugins, and skills (Python bindings for the integration catalog)"
5
5
  requires-python = ">=3.12"
6
+ dependencies = [
7
+ "deprecation>=2.1,<3",
8
+ "packaging>=24,<26",
9
+ "pydantic>=2.10,<3",
10
+ ]
6
11
 
7
12
  [build-system]
8
13
  requires = ["hatchling>=1.21"]
@@ -13,12 +13,50 @@ from ._version import __version__
13
13
  from .integrations import (
14
14
  INTEGRATION_CATALOG_SNAPSHOT,
15
15
  get_integration_catalog_entry,
16
+ get_integration_catalog_entry_model,
16
17
  list_integration_catalog,
18
+ list_integration_catalog_models,
19
+ )
20
+ from .integration_models import (
21
+ HttpConnectionOption,
22
+ IntegrationAuthConfig,
23
+ IntegrationCatalogEntry,
24
+ IntegrationCatalogSnapshot,
25
+ IntegrationConnectionOption,
26
+ IntegrationConnectionModel,
27
+ IntegrationHttpConfig,
28
+ IntegrationIdentityMapping,
29
+ IntegrationOAuthConfig,
30
+ IntegrationResourceDiscovery,
31
+ IntegrationTransport,
32
+ McpConnectionOption,
33
+ MarketplaceField,
34
+ SseTransport,
35
+ StdioTransport,
36
+ StreamableHttpTransport,
17
37
  )
18
38
 
19
39
  __all__ = [
20
40
  "INTEGRATION_CATALOG_SNAPSHOT",
41
+ "HttpConnectionOption",
42
+ "IntegrationAuthConfig",
43
+ "IntegrationCatalogEntry",
44
+ "IntegrationCatalogSnapshot",
45
+ "IntegrationConnectionOption",
46
+ "IntegrationConnectionModel",
47
+ "IntegrationHttpConfig",
48
+ "IntegrationIdentityMapping",
49
+ "IntegrationOAuthConfig",
50
+ "IntegrationResourceDiscovery",
51
+ "IntegrationTransport",
52
+ "MarketplaceField",
53
+ "McpConnectionOption",
54
+ "SseTransport",
55
+ "StdioTransport",
56
+ "StreamableHttpTransport",
21
57
  "__version__",
22
58
  "get_integration_catalog_entry",
59
+ "get_integration_catalog_entry_model",
23
60
  "list_integration_catalog",
61
+ "list_integration_catalog_models",
24
62
  ]
@@ -16,8 +16,8 @@ from importlib.metadata import PackageNotFoundError, version
16
16
 
17
17
  #: Fallback used only when the package is not installed (no dist metadata).
18
18
  #: Kept in lock-step with pyproject.toml/package.json by the version test.
19
- # x-release-please-update-version
20
- _FALLBACK_VERSION = "0.9.0"
19
+ # x-release-please-version
20
+ _FALLBACK_VERSION = "0.11.0"
21
21
 
22
22
  try:
23
23
  __version__: str = version("openhands-extensions")
@@ -0,0 +1,159 @@
1
+ """Versioned deprecation helpers for OpenHands extensions.
2
+
3
+ This module intentionally mirrors ``openhands.sdk.utils.deprecation`` so public
4
+ extension APIs use the same decorator, runtime warning, and model-input
5
+ compatibility behavior as the SDK.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import warnings
11
+ from collections.abc import Callable
12
+ from datetime import date
13
+ from functools import cache
14
+ from importlib.metadata import PackageNotFoundError, version as get_version
15
+ from typing import Any, TypeVar, cast
16
+
17
+ from deprecation import (
18
+ DeprecatedWarning,
19
+ UnsupportedWarning,
20
+ deprecated as _deprecated,
21
+ )
22
+ from packaging import version as pkg_version
23
+
24
+
25
+ _FuncT = TypeVar("_FuncT", bound=Callable[..., Any])
26
+
27
+
28
+ @cache
29
+ def _current_version() -> str:
30
+ try:
31
+ return get_version("openhands-extensions")
32
+ except PackageNotFoundError:
33
+ return "0.0.0"
34
+
35
+
36
+ def deprecated(
37
+ *,
38
+ deprecated_in: str,
39
+ removed_in: str | date | None,
40
+ current_version: str | None = None,
41
+ details: str = "",
42
+ ) -> Callable[[_FuncT], _FuncT]:
43
+ """Return a decorator that deprecates a callable with explicit metadata."""
44
+ base_decorator = _deprecated(
45
+ deprecated_in=deprecated_in,
46
+ removed_in=removed_in,
47
+ current_version=current_version or _current_version(),
48
+ details=details,
49
+ )
50
+
51
+ def decorator(func: _FuncT) -> _FuncT:
52
+ return cast(_FuncT, base_decorator(func))
53
+
54
+ return decorator
55
+
56
+
57
+ def _should_warn(
58
+ *,
59
+ deprecated_in: str | None,
60
+ removed_in: str | date | None,
61
+ current_version: str | None,
62
+ ) -> tuple[bool, bool]:
63
+ is_deprecated = False
64
+ is_unsupported = False
65
+
66
+ if isinstance(removed_in, date):
67
+ if date.today() >= removed_in:
68
+ is_unsupported = True
69
+ else:
70
+ is_deprecated = True
71
+ elif current_version:
72
+ current = pkg_version.parse(current_version)
73
+ if removed_in and current >= pkg_version.parse(str(removed_in)):
74
+ is_unsupported = True
75
+ elif deprecated_in and current >= pkg_version.parse(deprecated_in):
76
+ is_deprecated = True
77
+ else:
78
+ is_deprecated = True
79
+
80
+ return is_deprecated, is_unsupported
81
+
82
+
83
+ def warn_deprecated(
84
+ feature: str,
85
+ *,
86
+ deprecated_in: str,
87
+ removed_in: str | date | None,
88
+ current_version: str | None = None,
89
+ details: str = "",
90
+ stacklevel: int = 2,
91
+ ) -> None:
92
+ """Emit a deprecation warning for dynamic access to a legacy feature."""
93
+ current_version = current_version or _current_version()
94
+ is_deprecated, is_unsupported = _should_warn(
95
+ deprecated_in=deprecated_in,
96
+ removed_in=removed_in,
97
+ current_version=current_version,
98
+ )
99
+
100
+ if not (is_deprecated or is_unsupported):
101
+ return
102
+
103
+ warning_cls = UnsupportedWarning if is_unsupported else DeprecatedWarning
104
+ warning = warning_cls(feature, deprecated_in, removed_in, details)
105
+ warnings.warn(warning, stacklevel=stacklevel)
106
+
107
+
108
+ def warn_cleanup(
109
+ workaround: str,
110
+ *,
111
+ cleanup_by: str | date,
112
+ current_version: str | None = None,
113
+ details: str = "",
114
+ stacklevel: int = 2,
115
+ ) -> None:
116
+ """Emit a warning when a temporary workaround has reached its deadline."""
117
+ current_version = current_version or _current_version()
118
+
119
+ should_cleanup = False
120
+ if isinstance(cleanup_by, date):
121
+ should_cleanup = date.today() >= cleanup_by
122
+ else:
123
+ try:
124
+ current = pkg_version.parse(current_version)
125
+ target = pkg_version.parse(str(cleanup_by))
126
+ should_cleanup = current >= target
127
+ except pkg_version.InvalidVersion:
128
+ pass
129
+
130
+ if should_cleanup:
131
+ message = (
132
+ f"Cleanup required: {workaround}. "
133
+ f"This workaround was scheduled for removal by {cleanup_by}."
134
+ )
135
+ if details:
136
+ message += f" {details}"
137
+ warnings.warn(message, UserWarning, stacklevel=stacklevel)
138
+
139
+
140
+ def handle_deprecated_model_fields(
141
+ data: Any,
142
+ deprecated_fields: tuple[str, ...],
143
+ ) -> Any:
144
+ """Remove permanently supported legacy fields from Pydantic model input."""
145
+ if not isinstance(data, dict):
146
+ return data
147
+
148
+ for field in deprecated_fields:
149
+ data.pop(field, None)
150
+
151
+ return data
152
+
153
+
154
+ __all__ = [
155
+ "deprecated",
156
+ "warn_deprecated",
157
+ "warn_cleanup",
158
+ "handle_deprecated_model_fields",
159
+ ]
@@ -0,0 +1,233 @@
1
+ """Typed models for the OpenHands integration catalog.
2
+
3
+ The hand-authored JSON files under ``integrations/catalog`` remain the source
4
+ of truth. These models provide a Python representation of the public catalog
5
+ contract defined by ``integrations/catalog.schema.json`` and
6
+ ``integrations/index.d.ts``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Annotated, Any, Literal
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
14
+
15
+
16
+ NonEmptyString = Annotated[str, Field(min_length=1)]
17
+ HttpsUrl = Annotated[str, Field(pattern=r"^https://", min_length=8)]
18
+ IdentityPath = Annotated[
19
+ str,
20
+ Field(pattern=r"^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$", min_length=1),
21
+ ]
22
+ IntegrationAuthStrategy = Literal["none", "api_key", "bearer", "basic", "oauth2"]
23
+ IntegrationProvider = Literal["mcp", "http"]
24
+
25
+
26
+ class CatalogModel(BaseModel):
27
+ """Base model for strict, portable catalog data."""
28
+
29
+ model_config = ConfigDict(extra="forbid")
30
+
31
+ @model_validator(mode="before")
32
+ @classmethod
33
+ def reject_explicit_nulls(cls, values: Any) -> Any:
34
+ """Match the JSON Schema: optional fields may be omitted, never null."""
35
+ if not isinstance(values, dict):
36
+ return values
37
+
38
+ null_fields = [field for field, value in values.items() if value is None]
39
+ if null_fields:
40
+ raise ValueError(
41
+ "Catalog fields must be omitted instead of set to null: "
42
+ f"{', '.join(null_fields)}"
43
+ )
44
+ return values
45
+
46
+
47
+ class MarketplaceField(CatalogModel):
48
+ key: NonEmptyString
49
+ label: NonEmptyString
50
+ type: Literal["text", "password"]
51
+ placeholder: str | None = None
52
+ helperText: str | None = None
53
+ helperLink: HttpsUrl | None = None
54
+ required: bool
55
+
56
+ @model_validator(mode="after")
57
+ def password_fields_have_help(self) -> MarketplaceField:
58
+ if self.type == "password" and not self.helperText:
59
+ raise ValueError("Password marketplace fields require helperText.")
60
+ return self
61
+
62
+
63
+ class StreamableHttpTransport(CatalogModel):
64
+ kind: Literal["shttp"]
65
+ url: HttpsUrl
66
+ apiKeyOptional: bool | None = None
67
+ headerFields: list[MarketplaceField] | None = None
68
+ urlEditable: bool | None = None
69
+
70
+
71
+ class SseTransport(CatalogModel):
72
+ kind: Literal["sse"]
73
+ url: HttpsUrl
74
+ apiKeyOptional: bool | None = None
75
+ headerFields: list[MarketplaceField] | None = None
76
+ urlEditable: bool | None = None
77
+
78
+
79
+ class StdioTransport(CatalogModel):
80
+ kind: Literal["stdio"]
81
+ serverName: NonEmptyString
82
+ command: NonEmptyString
83
+ args: list[str]
84
+ envFields: list[MarketplaceField] | None = None
85
+ argFields: list[MarketplaceField] | None = None
86
+
87
+
88
+ IntegrationTransport = Annotated[
89
+ StreamableHttpTransport | SseTransport | StdioTransport,
90
+ Field(discriminator="kind"),
91
+ ]
92
+
93
+
94
+ class IntegrationOAuthConfig(CatalogModel):
95
+ authorizationUrl: HttpsUrl | None = None
96
+ tokenUrl: HttpsUrl | None = None
97
+ scopes: list[NonEmptyString] | None = None
98
+ optionalScopes: list[NonEmptyString] | None = None
99
+ toolScopes: list[NonEmptyString] | None = None
100
+ scopeSeparator: Literal["space", "comma"] | None = None
101
+ pkce: bool | None = None
102
+ clientAuthentication: Literal["basic", "body", "none"] | None = None
103
+ registrationUrl: HttpsUrl | None = None
104
+ additionalAuthorizationParams: dict[str, str] | None = None
105
+ additionalTokenParams: dict[str, str] | None = None
106
+
107
+
108
+ class IntegrationAuthConfig(CatalogModel):
109
+ strategy: IntegrationAuthStrategy
110
+ authModes: list[IntegrationAuthStrategy] | None = Field(default=None, min_length=1)
111
+ credentialLabel: NonEmptyString | None = None
112
+ credentialPlaceholder: NonEmptyString | None = None
113
+ credentialHelp: NonEmptyString | None = None
114
+ credentialSecretName: NonEmptyString | None = None
115
+ saveCredentialAsSecretByDefault: bool | None = None
116
+ apiKeyHeaderName: NonEmptyString | None = None
117
+ apiKeyOptional: bool | None = None
118
+ oauth: IntegrationOAuthConfig | None = None
119
+
120
+
121
+ class IntegrationHttpConfig(CatalogModel):
122
+ apiBaseUrl: HttpsUrl
123
+ openApiUrl: HttpsUrl
124
+
125
+
126
+ class IntegrationIdentityMapping(CatalogModel):
127
+ source: Literal["oauth_token_response", "access_token_claims", "identity_api"]
128
+ endpoint: HttpsUrl | None = None
129
+ externalPrincipalIdPath: IdentityPath
130
+ externalTenantIdPath: IdentityPath | None = None
131
+ externalResourceIdPath: IdentityPath | None = None
132
+ resourceNamePath: IdentityPath | None = None
133
+ resourceUrlPath: IdentityPath | None = None
134
+
135
+ @model_validator(mode="after")
136
+ def identity_api_requires_endpoint(self) -> IntegrationIdentityMapping:
137
+ if self.source == "identity_api" and not self.endpoint:
138
+ raise ValueError("identity_api mappings require an endpoint.")
139
+ if self.source != "identity_api" and self.endpoint:
140
+ raise ValueError("Only identity_api mappings may define an endpoint.")
141
+ return self
142
+
143
+
144
+ class IntegrationResourceDiscovery(CatalogModel):
145
+ endpoint: HttpsUrl
146
+ itemsPath: IdentityPath | None = None
147
+ externalResourceIdPath: IdentityPath
148
+ resourceNamePath: IdentityPath | None = None
149
+ resourceUrlPath: IdentityPath | None = None
150
+
151
+
152
+ class IntegrationConnectionModel(CatalogModel):
153
+ principalType: Literal["user", "bot", "service_account", "application"]
154
+ credentialScope: Literal["account", "tenant", "organization", "resource"]
155
+ resourceType: NonEmptyString
156
+ resourceCardinality: Literal["one", "many"]
157
+ selectionMode: Literal["automatic", "post_auth", "runtime"]
158
+ identityMapping: IntegrationIdentityMapping | None = None
159
+ resourceDiscovery: IntegrationResourceDiscovery | None = None
160
+
161
+ @model_validator(mode="after")
162
+ def post_auth_requires_resource_discovery(self) -> IntegrationConnectionModel:
163
+ if self.selectionMode == "post_auth" and not self.resourceDiscovery:
164
+ raise ValueError("post_auth connections require resourceDiscovery.")
165
+ return self
166
+
167
+
168
+ class McpConnectionOption(CatalogModel):
169
+ id: NonEmptyString
170
+ provider: Literal["mcp"]
171
+ transport: IntegrationTransport
172
+ auth: IntegrationAuthConfig
173
+ connectionModel: IntegrationConnectionModel | None = None
174
+
175
+
176
+ class HttpConnectionOption(CatalogModel):
177
+ id: NonEmptyString
178
+ provider: Literal["http"]
179
+ http: IntegrationHttpConfig
180
+ auth: IntegrationAuthConfig
181
+ connectionModel: IntegrationConnectionModel | None = None
182
+
183
+
184
+ IntegrationConnectionOption = Annotated[
185
+ McpConnectionOption | HttpConnectionOption,
186
+ Field(discriminator="provider"),
187
+ ]
188
+
189
+
190
+ class IntegrationCatalogEntry(CatalogModel):
191
+ id: NonEmptyString
192
+ name: NonEmptyString
193
+ description: NonEmptyString
194
+ categories: list[NonEmptyString] | None = Field(default=None, min_length=1)
195
+ appUrl: HttpsUrl | None = None
196
+ docsUrl: HttpsUrl
197
+ notes: NonEmptyString | None = None
198
+ iconBg: NonEmptyString | None = None
199
+ iconColor: NonEmptyString | None = None
200
+ logoUrl: HttpsUrl | None = None
201
+ keywords: list[NonEmptyString] | None = None
202
+ popularityRank: int | None = Field(default=None, ge=0)
203
+ installHint: NonEmptyString | None = None
204
+ connectionOptions: list[IntegrationConnectionOption] = Field(min_length=1)
205
+
206
+ @field_validator("categories", "keywords")
207
+ @classmethod
208
+ def list_values_are_unique(
209
+ cls, values: list[NonEmptyString] | None
210
+ ) -> list[NonEmptyString] | None:
211
+ if values is not None and len(values) != len(set(values)):
212
+ raise ValueError("Catalog list values must be unique.")
213
+ return values
214
+
215
+ @model_validator(mode="after")
216
+ def validate_auth_requirements(self) -> IntegrationCatalogEntry:
217
+ for option in self.connectionOptions:
218
+ if option.provider == "http" and option.auth.strategy == "api_key":
219
+ if not option.auth.apiKeyHeaderName:
220
+ raise ValueError(
221
+ "HTTP API-key connections require apiKeyHeaderName."
222
+ )
223
+ if option.provider == "http" and option.auth.strategy == "oauth2":
224
+ oauth = option.auth.oauth
225
+ if not oauth or not oauth.authorizationUrl or not oauth.tokenUrl:
226
+ raise ValueError(
227
+ "HTTP OAuth connections require authorizationUrl and tokenUrl."
228
+ )
229
+ return self
230
+
231
+
232
+ class IntegrationCatalogSnapshot(CatalogModel):
233
+ integrations: list[IntegrationCatalogEntry]