From 63843a9c046a3e5afbad1506651dde637c792487 Mon Sep 17 00:00:00 2001 From: nihalashetty Date: Fri, 14 Aug 2026 11:22:34 +0530 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20connectors=20=E2=80=94=20one-clic?= =?UTF-8?q?k=20OAuth=20integrations,=20per-user=20accounts,=20trigger=20id?= =?UTF-8?q?entity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Connectors tab: Gmail, Calendar, Drive, Sheets, Outlook, Slack, Notion, Linear, Atlassian, GitHub, HubSpot and Airtable. Click Connect, sign in on the vendor's page, approve — the connector expands into ordinary Forge rows (an AuthProvider, a ToolSet, one Tool per action), so its actions appear in Tools, on the canvas, and in agents with no separate connector runtime for the rest of the app to know about. Forge stays independent. The catalog is a directory of JSON manifests read at import time with zero network I/O (test-enforced), so this works identically on an air-gapped install. No third-party connector service, SDK, or hosted registry is involved. Credentials come from the deployment, never the UI A catalog connector's vendor OAuth app is read only from FORGE_CONNECTOR_OAUTH_APPS, keyed by credential group (one "google" entry covers Gmail/Calendar/Drive/Sheets). No route accepts a pasted credential for a catalog install; an unconfigured vendor reports itself unavailable and names the env key rather than degrading into a form that asks an end user for a client secret. Slack/Notion/Linear/Atlassian need no entry at all — they publish OAuth metadata and Forge registers a client dynamically (RFC 9728/8414/7591). Every account is personal Catalog connectors are per-user: tokens are stored under the connecting user's identity, every status is answered for the caller, and disconnecting affects only you. Pasted ("custom") manifests keep the credential form and the shared-account option — which is where a service account or an internal API with an API key belongs. Triggers carry an identity and an owner A webhook or schedule fires with nobody signed in, so a per-user connector had no token to resolve. Trigger.run_as_user_id names whose accounts an unattended run uses (the editor who saved the workflow); Trigger.scope says whether the automation is the project's or that person's, defaulting by role and driving who sees it. Neither is ever overwritten by a later edit. Also - $mime body directive builds and base64url-encodes RFC 2822 messages server-side. Gmail's send endpoint accepts nothing else, and asking a model to base64-encode by hand produced an opaque 400. - "Refresh actions" upgrades installed connectors in place (tool ids preserved, no re-consent), so a corrected manifest can reach projects that already installed it. - MCP client-side OAuth: httpx.Auth resolves provider headers per request, so tokens rotate without reconnecting; discovery asks as the consenting user. - Token endpoints: Accept: application/json (GitHub) and client_secret_basic (Airtable). - describe_mcp_error unwraps anyio ExceptionGroups, which reported every MCP failure as "unhandled errors in a TaskGroup". - Playground's workflow picker moved into the empty state, where it is seen before the first question. Migrations 0011 (connector_installs, mcp_clients.auth_provider_id), 0012 (run_as_user_id), 0013 (scope) — all idempotent and additive; existing rows keep current behaviour. --- .env.example | 22 + apps/api/forge/auth_providers/oauth_flow.py | 113 +++ apps/api/forge/auth_providers/resolver.py | 7 +- apps/api/forge/auth_providers/templates.py | 71 +- apps/api/forge/config.py | 43 + apps/api/forge/connectors/__init__.py | 31 + apps/api/forge/connectors/catalog.py | 124 +++ .../forge/connectors/catalog/airtable.json | 98 ++ .../forge/connectors/catalog/atlassian.json | 31 + apps/api/forge/connectors/catalog/github.json | 120 +++ apps/api/forge/connectors/catalog/gmail.json | 229 +++++ .../connectors/catalog/google-calendar.json | 253 +++++ .../connectors/catalog/google-drive.json | 155 +++ .../connectors/catalog/google-sheets.json | 182 ++++ .../api/forge/connectors/catalog/hubspot.json | 101 ++ apps/api/forge/connectors/catalog/linear.json | 32 + apps/api/forge/connectors/catalog/notion.json | 32 + .../api/forge/connectors/catalog/outlook.json | 253 +++++ apps/api/forge/connectors/catalog/slack.json | 36 + .../connectors/examples/custom-rest-api.json | 44 + .../forge/connectors/examples/discord.json | 62 ++ apps/api/forge/connectors/examples/jira.json | 94 ++ .../forge/connectors/examples/pagerduty.json | 76 ++ .../forge/connectors/examples/sendgrid.json | 51 + .../forge/connectors/examples/shopify.json | 89 ++ .../forge/connectors/examples/slack-api.json | 77 ++ .../api/forge/connectors/examples/stripe.json | 89 ++ .../api/forge/connectors/examples/twilio.json | 64 ++ .../forge/connectors/examples/zendesk.json | 104 ++ apps/api/forge/connectors/install.py | 829 ++++++++++++++++ apps/api/forge/connectors/manifest.py | 299 ++++++ apps/api/forge/connectors/mcp_auth.py | 164 +++ apps/api/forge/main.py | 2 + apps/api/forge/models/__init__.py | 3 +- apps/api/forge/models/entities.py | 66 ++ apps/api/forge/routers/connectors.py | 630 ++++++++++++ apps/api/forge/routers/mcp_clients.py | 50 +- apps/api/forge/routers/oauth.py | 95 +- apps/api/forge/routers/triggers.py | 173 +++- apps/api/forge/routers/workflows.py | 18 +- apps/api/forge/schemas/dto.py | 1 + apps/api/forge/services/dispatch.py | 8 +- apps/api/forge/services/projects.py | 3 + apps/api/forge/services/runtime.py | 7 +- apps/api/forge/services/triggers.py | 39 +- apps/api/forge/services/workflows.py | 23 +- apps/api/forge/tools/mcp.py | 182 +++- apps/api/forge/tools/rest.py | 22 +- .../migrations/versions/0011_connectors.py | 67 ++ .../versions/0012_trigger_run_as.py | 40 + .../migrations/versions/0013_trigger_scope.py | 44 + apps/api/tests/test_body_template_loop.py | 93 +- apps/api/tests/test_connectors.py | 935 ++++++++++++++++++ apps/api/tests/test_mcp.py | 2 +- apps/api/tests/test_mcp_auth.py | 177 ++++ apps/api/tests/test_projects.py | 4 +- apps/api/tests/test_triggers.py | 292 +++++- apps/web/app/page.tsx | 17 +- apps/web/components/canvas/AgentConfig.tsx | 18 +- apps/web/components/icons.tsx | 9 + apps/web/components/screens/connectors.tsx | 623 ++++++++++++ apps/web/components/screens/mcp.tsx | 10 +- apps/web/components/screens/platform.tsx | 68 +- apps/web/components/screens/playground.tsx | 34 +- apps/web/components/screens/tools.tsx | 6 +- apps/web/components/screens/workflows.tsx | 81 +- apps/web/components/shell.tsx | 1 + apps/web/lib/api.ts | 171 +++- apps/web/lib/data.ts | 2 +- docker-compose.yml | 12 + docs/CONNECTORS_PLAN.md | 417 ++++++++ docs/MANUAL.md | 113 ++- 72 files changed, 8382 insertions(+), 151 deletions(-) create mode 100644 apps/api/forge/auth_providers/oauth_flow.py create mode 100644 apps/api/forge/connectors/__init__.py create mode 100644 apps/api/forge/connectors/catalog.py create mode 100644 apps/api/forge/connectors/catalog/airtable.json create mode 100644 apps/api/forge/connectors/catalog/atlassian.json create mode 100644 apps/api/forge/connectors/catalog/github.json create mode 100644 apps/api/forge/connectors/catalog/gmail.json create mode 100644 apps/api/forge/connectors/catalog/google-calendar.json create mode 100644 apps/api/forge/connectors/catalog/google-drive.json create mode 100644 apps/api/forge/connectors/catalog/google-sheets.json create mode 100644 apps/api/forge/connectors/catalog/hubspot.json create mode 100644 apps/api/forge/connectors/catalog/linear.json create mode 100644 apps/api/forge/connectors/catalog/notion.json create mode 100644 apps/api/forge/connectors/catalog/outlook.json create mode 100644 apps/api/forge/connectors/catalog/slack.json create mode 100644 apps/api/forge/connectors/examples/custom-rest-api.json create mode 100644 apps/api/forge/connectors/examples/discord.json create mode 100644 apps/api/forge/connectors/examples/jira.json create mode 100644 apps/api/forge/connectors/examples/pagerduty.json create mode 100644 apps/api/forge/connectors/examples/sendgrid.json create mode 100644 apps/api/forge/connectors/examples/shopify.json create mode 100644 apps/api/forge/connectors/examples/slack-api.json create mode 100644 apps/api/forge/connectors/examples/stripe.json create mode 100644 apps/api/forge/connectors/examples/twilio.json create mode 100644 apps/api/forge/connectors/examples/zendesk.json create mode 100644 apps/api/forge/connectors/install.py create mode 100644 apps/api/forge/connectors/manifest.py create mode 100644 apps/api/forge/connectors/mcp_auth.py create mode 100644 apps/api/forge/routers/connectors.py create mode 100644 apps/api/migrations/versions/0011_connectors.py create mode 100644 apps/api/migrations/versions/0012_trigger_run_as.py create mode 100644 apps/api/migrations/versions/0013_trigger_scope.py create mode 100644 apps/api/tests/test_connectors.py create mode 100644 apps/api/tests/test_mcp_auth.py create mode 100644 apps/web/components/screens/connectors.tsx create mode 100644 docs/CONNECTORS_PLAN.md diff --git a/.env.example b/.env.example index afd03d4..ba8c1ff 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,28 @@ FORGE_EGRESS_ALLOW_PRIVATE_HOSTS=[] # e.g. ["localhost","127.0.0.1"] (Dock # resolves to a different host per deploy (dev/qa/prod). A template referencing a key NOT in this # map fails the call loudly. Blank/unset = {}. FORGE_TOOL_VARS= # e.g. {"api_base":"https://api.example.com"} +# --- Connectors: the vendor OAuth apps this deployment signs people in with --- +# This is the ONLY place a bundled connector's credentials live. Register each vendor app once, +# put it here, restart - and from then on every user just clicks Connect, signs in with their own +# account on the vendor's page, and approves. Nobody types a client secret into Forge's UI, and +# a connector whose group is missing here says so instead of showing a form. +# +# Keyed by CREDENTIAL GROUP, which is the vendor, not the connector: one "google" entry covers +# Gmail, Calendar, Drive and Sheets; one "microsoft" entry covers Outlook. +# google Gmail · Calendar · Drive · Sheets microsoft Outlook +# github GitHub hubspot HubSpot +# airtable Airtable +# Slack, Notion, Linear and Atlassian need NO entry: they publish OAuth metadata and Forge +# registers a client with them automatically (RFC 7591) the first time someone connects. +# +# Redirect/callback URL to register with every vendor (must match exactly): +# /v1/oauth/callback dev: http://localhost:8000/v1/oauth/callback +# +# Google: create a "Desktop app" OAuth client - Google documents that client type's secret as +# NOT confidential, and it accepts loopback/localhost redirects with no per-install +# registration. Until the app passes Google verification it is capped at 100 users and shows +# an "unverified app" warning; Gmail read/modify are RESTRICTED scopes needing a CASA audit. +FORGE_CONNECTOR_OAUTH_APPS= # {"google":{"client_id":"…","client_secret":"…"},"github":{…}} # Deployment-wide fallback for a per-user auth provider's `token_ctx_key`: the run-context key an # integration forwards its per-user token under (via X-Forge-Context) when a provider doesn't set # its own. Empty = off. e.g. user_token diff --git a/apps/api/forge/auth_providers/oauth_flow.py b/apps/api/forge/auth_providers/oauth_flow.py new file mode 100644 index 0000000..e8ac629 --- /dev/null +++ b/apps/api/forge/auth_providers/oauth_flow.py @@ -0,0 +1,113 @@ +"""Shared authorization-code connect flow (PKCE + signed state). + +Both the Auth Providers screen and the Connectors screen start the same 3-legged OAuth dance +against the same `/v1/oauth/callback`. Keeping the URL construction here means the security +properties - PKCE S256, a signed short-lived state, and carrying only the provider's declared +per-user dims - are implemented once instead of drifting between two call sites. +""" + +from __future__ import annotations + +import base64 +import hashlib +import secrets as _secrets +from urllib.parse import urlencode + +from forge.config import settings +from forge.models import AuthProvider +from forge.secrets.store import SecretStore +from forge.security import create_state_token + + +class OAuthNotConfigured(ValueError): + """The provider is missing something the authorize step needs (client id, authorize URL).""" + + +def redirect_uri(cfg: dict) -> str: + return cfg.get("redirect_uri") or f"{settings.public_base_url.rstrip('/')}/v1/oauth/callback" + + +def token_request(cfg: dict, data: dict) -> tuple[dict, dict]: + """Form body + headers for a token-endpoint POST, given a body that already carries + `client_id`/`client_secret`. Returns `(data, headers)`. + + Two things a naive POST gets wrong against real servers: + + * `Accept: application/json`. GitHub's token endpoint answers `application/x-www-form- + urlencoded` without it, so a perfectly successful exchange then dies in `.json()`. + * client_secret_basic. RFC 6749 §2.3.1 makes HTTP Basic the method a server MUST support + and form-post the one it MAY; a few (Airtable) accept only Basic. `token_auth: "basic"` + on the provider config moves the secret into the Authorization header. `client_id` stays + in the body, which every server tolerates and some still require. + """ + headers = {"Accept": "application/json"} + if cfg.get("token_auth") != "basic": + return {k: v for k, v in data.items() if v is not None}, headers + client_id, client_secret = data.get("client_id") or "", data.get("client_secret") or "" + creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() + headers["Authorization"] = "Basic " + creds + return {k: v for k, v in data.items() if v is not None and k != "client_secret"}, headers + + +async def build_authorize_url( + ap: AuthProvider, *, tenant_id: str, project_id: str, context: dict | None = None, + secrets: SecretStore | None = None, +) -> str: + """The provider's authorize URL, carrying a signed state that binds the callback back to + this tenant/project/provider (and, for a per-user provider, to the end user being connected). + + The PKCE verifier rides inside the SIGNED state. That is safe for a CONFIDENTIAL client - + which is what Forge always registers, since it holds the client secret server-side in its + own encrypted store - because the token exchange also requires that secret. A public client + (no secret) would need the verifier held server-side instead; see routers/oauth.py. + """ + cfg = ap.config or {} + store = secrets or SecretStore() + client_id = None + if cfg.get("client_id_ref"): + try: + client_id = await store.read_ref(tenant_id=tenant_id, project_id=project_id, ref=cfg["client_id_ref"]) + except Exception as e: # noqa: BLE001 - surface as a configuration problem, not a 500 + raise OAuthNotConfigured("client_id secret is not set") from e + if not client_id: + raise OAuthNotConfigured("client_id secret is not set") + if not cfg.get("authorize_url"): + raise OAuthNotConfigured("authorize_url is not configured") + + verifier = _secrets.token_urlsafe(64) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") + claims = {"tid": tenant_id, "pid": project_id, "ap": ap.id, "cv": verifier} + per_user = cfg.get("per_user_context_keys") or [] + ctx = context or {} + user_ctx = {k: ctx[k] for k in per_user if k in ctx} + if user_ctx: + claims["ctx"] = user_ctx + + q = { + "response_type": "code", + "client_id": str(client_id), + "redirect_uri": redirect_uri(cfg), + "state": create_state_token(claims), + "code_challenge": challenge, + "code_challenge_method": "S256", + } + if cfg.get("scope"): + q["scope"] = cfg["scope"] + # RFC 8707: bind the issued token to the resource it is for, so a token minted for one MCP + # server can't be replayed against another. Only sent when the provider names a resource + # (connectors set it to the MCP server URL); omitted otherwise to avoid upsetting servers + # that reject unknown parameters. + if cfg.get("resource"): + q["resource"] = cfg["resource"] + # Providers that only return a refresh_token when explicitly asked (Google, and anything + # modeled on it). Harmless elsewhere - it is only sent when the manifest opts in. + for key in ("access_type", "prompt"): + if cfg.get(key): + q[key] = cfg[key] + # Anything else a specific vendor requires on the authorize URL. Applied last but never over + # the protocol parameters above - a manifest must not be able to redirect the callback or + # weaken PKCE by declaring `redirect_uri` or `code_challenge_method` as an "extra". + for key, value in (cfg.get("authorize_params") or {}).items(): + if key not in q: + q[key] = str(value) + return f"{cfg['authorize_url']}?{urlencode(q)}" diff --git a/apps/api/forge/auth_providers/resolver.py b/apps/api/forge/auth_providers/resolver.py index 6f94e34..b8efd02 100644 --- a/apps/api/forge/auth_providers/resolver.py +++ b/apps/api/forge/auth_providers/resolver.py @@ -274,9 +274,14 @@ async def _refresh_oauth(self, provider, cfg: dict, read, bundle: dict, tenant_i "client_secret": await read(cfg.get("client_secret_ref")), } client = client or shared_async_client() + # Same client-auth method and Accept header the initial exchange used - a provider that + # only speaks client_secret_basic rejects the refresh just as readily as the exchange. + from forge.auth_providers.oauth_flow import token_request + + form, headers = token_request(cfg, data) r = await guarded_request( client, "POST", cfg["token_url"], - data={k: v for k, v in data.items() if v is not None}, timeout=30, follow_redirects=True, + data=form, headers=headers, timeout=30, follow_redirects=True, ) r.raise_for_status() body = r.json() diff --git a/apps/api/forge/auth_providers/templates.py b/apps/api/forge/auth_providers/templates.py index d468cf2..c526f42 100644 --- a/apps/api/forge/auth_providers/templates.py +++ b/apps/api/forge/auth_providers/templates.py @@ -60,37 +60,88 @@ def render_template(s: str, vars: dict, *, strict_ns: Collection[str] = ()) -> A return _TOKEN.sub(lambda mm: _sub_one(mm, vars, strict_ns), s) -def has_each_directive(obj: Any) -> bool: - """True if `obj` (a parsed JSON structure) contains a `$each` loop directive anywhere - i.e. - a dict that has "$each" as a KEY. Used to decide whether a body template needs structural +#: Directives that need STRUCTURAL rendering (parse the JSON, then walk it) rather than plain +#: string substitution, because they produce a value the surrounding text can't express. +DIRECTIVES = ("$each", "$mime") + + +def has_structural_directive(obj: Any) -> bool: + """True if `obj` (a parsed JSON structure) contains a `$each` or `$mime` directive anywhere - + i.e. a dict that has one as a KEY. Used to decide whether a body template needs structural rendering; a literal "$each" appearing inside a string value is NOT a directive and must not trigger it (that would silently change type coercion for unrelated templates).""" if isinstance(obj, dict): - if "$each" in obj: + if any(d in obj for d in DIRECTIVES): return True - return any(has_each_directive(v) for v in obj.values()) + return any(has_structural_directive(v) for v in obj.values()) if isinstance(obj, list): - return any(has_each_directive(v) for v in obj) + return any(has_structural_directive(v) for v in obj) return False def render_value(obj: Any, vars: dict, *, allow_each: bool = False, strict_ns: Collection[str] = ()) -> Any: - """Walk a parsed JSON structure, rendering `{{token}}` leaves. `$each` loop directives are - honored ONLY when `allow_each=True` (the REST body-template path opts in); every other caller - - auth token_fetch/extract rules, data-node payloads - passes the default False, so a literal - object key named "$each" stays an ordinary key instead of being reinterpreted as a loop. + """Walk a parsed JSON structure, rendering `{{token}}` leaves. Structural directives (`$each`, + `$mime`) are honored ONLY when `allow_each=True` (the REST body-template path opts in); every + other caller - auth token_fetch/extract rules, data-node payloads - passes the default False, + so a literal object key named "$each" stays an ordinary key instead of being reinterpreted. `strict_ns` propagates the fail-loud namespaces (e.g. env) to every leaf.""" if isinstance(obj, str): return render_template(obj, vars, strict_ns=strict_ns) if isinstance(obj, dict): if allow_each and "$each" in obj: return _render_each(obj, vars, strict_ns=strict_ns) + if allow_each and "$mime" in obj: + return _render_mime(obj["$mime"], vars, strict_ns=strict_ns) return {k: render_value(v, vars, allow_each=allow_each, strict_ns=strict_ns) for k, v in obj.items()} if isinstance(obj, list): return [render_value(v, vars, allow_each=allow_each, strict_ns=strict_ns) for v in obj] return obj +#: Header name -> the key a `$mime` spec uses for it. +_MIME_HEADERS = ( + ("To", "to"), ("Cc", "cc"), ("Bcc", "bcc"), ("From", "from"), ("Reply-To", "reply_to"), + ("Subject", "subject"), ("In-Reply-To", "in_reply_to"), ("References", "references"), +) + + +def _render_mime(spec: Any, vars: dict, *, strict_ns: Collection[str] = ()) -> str: + """Build an RFC 2822 message from `{to, subject, text, ...}` and return it base64url-encoded. + + This exists because Gmail's send endpoint accepts ONLY a base64url-encoded MIME message. + Declaring that as a tool argument means asking a language model to base64-encode by hand - + which it cannot do reliably - and the malformed result comes back as an opaque HTTP 400 with + nothing in it to debug. So the model supplies the fields a person would type and the encoding + happens here, where stdlib `email` gets header encoding, non-ASCII subjects, address lists + and CRLF line endings right. + + Address fields accept a string or a list. Supply `text` (and optionally `html` for a + multipart/alternative). Empty/absent headers are omitted rather than sent blank. + """ + import base64 + from email.message import EmailMessage + from email.policy import SMTP + + if not isinstance(spec, dict): + raise ValueError("$mime expects an object, e.g. {\"to\": \"…\", \"subject\": \"…\", \"text\": \"…\"}") + resolved = {k: render_value(v, vars, strict_ns=strict_ns) for k, v in spec.items()} + + msg = EmailMessage() + for header, key in _MIME_HEADERS: + val = resolved.get(key) + if isinstance(val, (list, tuple)): + val = ", ".join(str(v).strip() for v in val if str(v).strip()) + if val is None or str(val).strip() == "": + continue + msg[header] = str(val).strip() + msg.set_content(str(resolved.get("text") or resolved.get("body") or "")) + html = resolved.get("html") + if html: + msg.add_alternative(str(html), subtype="html") + # SMTP policy => CRLF line endings, as RFC 2822 requires. + return base64.urlsafe_b64encode(msg.as_bytes(policy=SMTP)).decode() + + def _render_each(directive: dict, vars: dict, *, strict_ns: Collection[str] = ()) -> list: """Expand a `{"$each": "{{input.rows}}", "$as": "row", "$do": {...}}` loop directive into a list: render `$do` once per item of the array `$each` resolves to, with the item bound under diff --git a/apps/api/forge/config.py b/apps/api/forge/config.py index d8517a5..bfe4e3c 100644 --- a/apps/api/forge/config.py +++ b/apps/api/forge/config.py @@ -100,6 +100,34 @@ def _parse_tool_vars(cls, v: object) -> dict[str, str]: raise ValueError('FORGE_TOOL_VARS must be a JSON object, e.g. {"api_base":"https://..."}') return {str(k): str(val) for k, val in parsed.items()} + @field_validator("connector_oauth_apps", mode="before") + @classmethod + def _parse_connector_oauth_apps(cls, v: object) -> dict[str, dict]: + """{"google": {"client_id": "...", "client_secret": "..."}} - one entry per credential + group. Fails loudly on a malformed value: a silently-empty map would send every user + back to pasting credentials with no indication why.""" + if v is None: + return {} + if isinstance(v, dict): + return {str(k): dict(val) for k, val in v.items() if isinstance(val, dict)} + s = str(v).strip() + if not s: + return {} + import json as _json + + parsed = _json.loads(s) + if not isinstance(parsed, dict): + raise ValueError( + 'FORGE_CONNECTOR_OAUTH_APPS must be a JSON object, e.g. ' + '{"google":{"client_id":"...","client_secret":"..."}}' + ) + out: dict[str, dict] = {} + for group, creds in parsed.items(): + if not isinstance(creds, dict): + raise ValueError(f"FORGE_CONNECTOR_OAUTH_APPS['{group}'] must be an object of credential values") + out[str(group)] = {str(k): str(val) for k, val in creds.items()} + return out + # --- App --- app_name: str = "Forge" environment: str = "development" @@ -208,6 +236,21 @@ def _parse_tool_vars(cls, v: object) -> dict[str, str]: # the call with a clear error (rather than silently sending a broken URL). # Env: FORGE_TOOL_VARS='{"api_base":"https://api.example.com"}'. Blank/unset = {}. tool_vars: Annotated[dict[str, str], NoDecode] = Field(default_factory=dict) + # --- Connector OAuth apps (deployment-wide) ------------------------------------------- + # Pre-registered vendor OAuth apps, keyed by a connector's `credential_group`. When a group + # is present here, installing any connector in that family asks the operator for NOTHING: + # the install seeds the project's credentials from this map and the user goes straight to + # "Connect account" -> vendor consent screen -> done. That is the one-click experience. + # + # Register ONE app per vendor for the whole deployment (or per fleet). For Google this is a + # "Desktop app" OAuth client, whose client secret Google explicitly documents as NOT + # confidential ("the client secret is obviously not treated as a secret") because it ships + # inside distributed software - which is precisely this case. Loopback/localhost redirects + # need no per-install registration for that client type. + # + # Env: FORGE_CONNECTOR_OAUTH_APPS='{"google":{"client_id":"...","client_secret":"..."}}' + # Unset = each project registers its own app and pastes the credentials (previous behavior). + connector_oauth_apps: Annotated[dict[str, dict], NoDecode] = Field(default_factory=dict) # Code tools run RestrictedPython (AST-sandboxed) but NOT OS-isolated: no CPU/memory # bound and a runaway thread can't be force-killed. RestrictedPython is a hardening # layer, not a sandbox, so it is OFF by default. Only enable it on a trusted, single- diff --git a/apps/api/forge/connectors/__init__.py b/apps/api/forge/connectors/__init__.py new file mode 100644 index 0000000..8ec62c3 --- /dev/null +++ b/apps/api/forge/connectors/__init__.py @@ -0,0 +1,31 @@ +"""Connectors - pre-built integrations (Slack, Gmail, Outlook, …) as installable manifests. + +A connector is a RECIPE, not a runtime. Installing one expands a manifest into the entities +Forge already executes: + + manifest -> AuthProvider (credentials + refresh) + -> ToolSet (the folder / MCP toolset / agent grant unit) + -> Tool * N (rest_api tools, or mcp tools behind an McpClient) + +Nothing downstream knows a connector exists: agents, the workflow `tool_call` node, the MCP +server, traces, and cost accounting all see ordinary tools. That is deliberate - it means the +connector layer can never become a second execution path that drifts from the first, and an +installed connector survives the connector subsystem being removed entirely. + +Independence: the catalog is a directory of JSON files shipped inside this package. Loading it +performs no network I/O and requires no third-party service, SDK, or API key. Optional +authoring-time sources (the public MCP registry) are strictly opt-in and never required to +install or run a connector. +""" + +from forge.connectors.catalog import CATALOG, get_manifest, list_manifests +from forge.connectors.manifest import ConnectorManifest, ManifestError, parse_manifest + +__all__ = [ + "CATALOG", + "ConnectorManifest", + "ManifestError", + "get_manifest", + "list_manifests", + "parse_manifest", +] diff --git a/apps/api/forge/connectors/catalog.py b/apps/api/forge/connectors/catalog.py new file mode 100644 index 0000000..4dcc87c --- /dev/null +++ b/apps/api/forge/connectors/catalog.py @@ -0,0 +1,124 @@ +"""The bundled connector catalog - JSON manifests shipped inside the package. + +Loading is lazy, cached, and performs NO network I/O: the catalog is a directory read. That is +the whole independence guarantee in one function - Forge boots, lists connectors, and installs +them on a machine with no outbound internet access at all (the connector's own API is the only +thing it ever talks to, and only when a tool actually runs). + +A malformed manifest is skipped with a logged warning rather than taking down the catalog: one +bad file in a 25-file directory must not blank the Connectors screen. +""" + +from __future__ import annotations + +import json +import logging +from functools import lru_cache +from pathlib import Path + +from forge.connectors.manifest import ConnectorManifest, ManifestError, parse_manifest + +log = logging.getLogger("forge.connectors") + +CATALOG_DIR = Path(__file__).parent / "catalog" +#: Manifests that are shipped but NOT in the gallery, because they can't honour its promise: +#: one click, no keys, your own account. An API key, a bot token or a per-tenant subdomain has +#: to be typed by somebody, so these are offered as starting points for the custom-connector +#: path instead of as a form bolted onto a screen that says there are no forms. +EXAMPLES_DIR = Path(__file__).parent / "examples" + + +def _read_dir(directory: Path) -> dict[str, tuple[ConnectorManifest, dict]]: + """Parsed manifests paired with the JSON exactly as authored. + + The raw document is kept because the custom-connector form offers examples as a STARTING + POINT to edit: round-tripping a parsed model there would either bury the author's file under + every defaulted field or, with defaults excluded, silently drop the backend discriminator and + produce a manifest that no longer validates. + """ + out: dict[str, tuple[ConnectorManifest, dict]] = {} + if not directory.is_dir(): + return out + for path in sorted(directory.glob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + manifest = parse_manifest(data) + except (OSError, json.JSONDecodeError, ManifestError) as e: + log.warning("skipping connector manifest %s: %s", path.name, e) + continue + if manifest.slug in out: + log.warning("duplicate connector slug %r in %s - keeping the first", manifest.slug, path.name) + continue + out[manifest.slug] = (manifest, data) + return out + + +@lru_cache(maxsize=1) +def _load() -> dict[str, ConnectorManifest]: + return {slug: m for slug, (m, _) in _read_dir(CATALOG_DIR).items()} + + +@lru_cache(maxsize=1) +def _load_examples() -> dict[str, tuple[ConnectorManifest, dict]]: + return _read_dir(EXAMPLES_DIR) + + +def list_examples() -> list[tuple[ConnectorManifest, dict]]: + """Each example as `(manifest, source_json)`, name-sorted.""" + return sorted(_load_examples().values(), key=lambda pair: pair[0].name.lower()) + + +class _Catalog: + """Dict-like view over the bundled manifests (so callers can do `CATALOG["slack"]`).""" + + def __getitem__(self, slug: str) -> ConnectorManifest: + return _load()[slug] + + def __contains__(self, slug: object) -> bool: + return slug in _load() + + def __iter__(self): + return iter(_load().values()) + + def __len__(self) -> int: + return len(_load()) + + def get(self, slug: str) -> ConnectorManifest | None: + return _load().get(slug) + + +CATALOG = _Catalog() + + +def list_manifests() -> list[ConnectorManifest]: + """Every bundled manifest, name-sorted (the order the gallery renders in).""" + return sorted(_load().values(), key=lambda m: m.name.lower()) + + +def get_manifest(slug: str) -> ConnectorManifest | None: + return _load().get(slug) + + +def categories() -> list[str]: + seen: list[str] = [] + for m in list_manifests(): + for c in m.categories: + if c not in seen: + seen.append(c) + return sorted(seen) + + +def roles() -> list[str]: + """Every role any manifest claims to be popular for - drives the "Popular for " + picker. Sorted, with the most-referenced roles first so the default lands on a useful one.""" + counts: dict[str, int] = {} + for m in list_manifests(): + for r in m.roles: + counts[r] = counts.get(r, 0) + 1 + return [r for r, _ in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0].lower()))] + + +def reload_catalog() -> None: + """Drop the cache (tests, and a hot-reload during catalog authoring).""" + _load.cache_clear() + _load_examples.cache_clear() diff --git a/apps/api/forge/connectors/catalog/airtable.json b/apps/api/forge/connectors/catalog/airtable.json new file mode 100644 index 0000000..ad83ce5 --- /dev/null +++ b/apps/api/forge/connectors/catalog/airtable.json @@ -0,0 +1,98 @@ +{ + "format": "forge.connector/1", + "slug": "airtable", + "name": "Airtable", + "version": "2.0.0", + "publisher": "forge", + "summary": "List, create, and update records in the Airtable bases you have access to.", + "categories": ["database", "productivity"], + "roles": ["Operations", "Marketing", "Product Manager"], + "icon": "grid", + "docs_url": "https://airtable.com/developers/web/api/introduction", + "setup_url": "https://airtable.com/create/oauth", + "auth": { + "kind": "oauth2_authorization_code", + "authorize_url": "https://airtable.com/oauth2/v1/authorize", + "token_url": "https://airtable.com/oauth2/v1/token", + "token_auth": "basic", + "scopes": ["data.records:read", "data.records:write", "schema.bases:read"], + "per_user": "required", + "credential_group": "airtable", + "setup_help": "Register one OAuth integration at airtable.com/create/oauth with the callback URL below, then put its Client ID and Client secret in FORGE_CONNECTOR_OAUTH_APPS under \"airtable\". Each person then grants access to their own bases.", + "setup": [ + { "key": "client_id", "label": "Client ID", "secret": true, "required": true }, + { "key": "client_secret", "label": "Client secret", "secret": true, "required": true } + ] + }, + "egress_hosts": ["api.airtable.com"], + "backend": { + "type": "rest", + "base_url": "https://api.airtable.com/v0", + "actions": [ + { + "name": "airtable_list_bases", + "display_name": "List bases", + "description": "List the Airtable bases this account can reach, with their ids. Call this first when you don't already know the base id.", + "request": { + "method": "GET", + "url_template": "/meta/bases", + "fields": [] + }, + "response": { "projection_jmespath": "bases[].{id: id, name: name}" } + }, + { + "name": "airtable_list_records", + "display_name": "List records", + "description": "List records from a table, optionally filtered with an Airtable formula, e.g. {Status}='Open'.", + "request": { + "method": "GET", + "url_template": "/{base_id}/{table}", + "fields": [ + { "path": "base_id", "in": "path", "type": "string", "required": true, "llm_visible": true, "description": "Base id (app…), from list bases." }, + { "path": "table", "in": "path", "type": "string", "required": true, "llm_visible": true, "description": "Table name or id." }, + { "path": "filterByFormula", "in": "query", "type": "string", "required": false, "llm_visible": true, "description": "Airtable formula filter." }, + { "path": "maxRecords", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 20 }, + { "path": "view", "in": "query", "type": "string", "required": false, "llm_visible": true, "description": "Restrict to a named view." } + ] + }, + "response": { "projection_jmespath": "records[].{id: id, fields: fields}" } + }, + { + "name": "airtable_create_record", + "display_name": "Create record", + "description": "Create one record. `fields` is an object of column name -> value.", + "request": { + "method": "POST", + "url_template": "/{base_id}/{table}", + "fields": [ + { "path": "base_id", "in": "path", "type": "string", "required": true, "llm_visible": true, "description": "Base id (app…)." }, + { "path": "table", "in": "path", "type": "string", "required": true, "llm_visible": true, "description": "Table name or id." }, + { "path": "fields", "in": "body", "type": "object", "required": true, "llm_visible": true, "description": "Column name -> value." } + ], + "body_template": "{\"fields\":{{input.fields}}}" + }, + "response": { "projection_jmespath": "{id: id, fields: fields}" } + }, + { + "name": "airtable_update_record", + "display_name": "Update record", + "description": "Update fields on an existing record. Only the fields supplied are changed.", + "request": { + "method": "PATCH", + "url_template": "/{base_id}/{table}/{record_id}", + "fields": [ + { "path": "base_id", "in": "path", "type": "string", "required": true, "llm_visible": true, "description": "Base id (app…)." }, + { "path": "table", "in": "path", "type": "string", "required": true, "llm_visible": true }, + { "path": "record_id", "in": "path", "type": "string", "required": true, "llm_visible": true, "description": "Record id (rec…)." }, + { "path": "fields", "in": "body", "type": "object", "required": true, "llm_visible": true, "description": "Column name -> new value." } + ], + "body_template": "{\"fields\":{{input.fields}}}" + }, + "response": { "projection_jmespath": "{id: id, fields: fields}" } + } + ] + }, + "toolset": { + "description": "Airtable: find bases, then list, create, and update records." + } +} diff --git a/apps/api/forge/connectors/catalog/atlassian.json b/apps/api/forge/connectors/catalog/atlassian.json new file mode 100644 index 0000000..75ecfba --- /dev/null +++ b/apps/api/forge/connectors/catalog/atlassian.json @@ -0,0 +1,31 @@ +{ + "format": "forge.connector/1", + "slug": "atlassian", + "name": "Atlassian (Jira & Confluence)", + "version": "1.0.0", + "publisher": "forge", + "summary": "Search and update Jira issues and Confluence pages in Atlassian Cloud.", + "categories": ["project-management", "docs"], + "roles": ["Software Engineer", "Product Manager", "Engineering Manager", "Support"], + "icon": "layout-dashboard", + "docs_url": "https://developer.atlassian.com/cloud/jira/platform/rest/v3/", + "auth": { + "kind": "oauth2_authorization_code", + "discover": true, + "per_user": "required", + "setup_help": "Atlassian hosts an official remote MCP server for Jira and Confluence Cloud. Connect with your Atlassian account; site access follows your own permissions.", + "setup": [ + { "key": "client_id", "label": "OAuth client ID", "secret": true, "required": false, "help": "Leave blank to let Forge register automatically." }, + { "key": "client_secret", "label": "OAuth client secret", "secret": true, "required": false } + ] + }, + "egress_hosts": ["mcp.atlassian.com", "api.atlassian.com", "auth.atlassian.com"], + "backend": { + "type": "mcp", + "url": "https://mcp.atlassian.com/v1/sse", + "transport": "sse" + }, + "toolset": { + "description": "Atlassian Cloud: Jira issue search and updates, Confluence page search and creation." + } +} diff --git a/apps/api/forge/connectors/catalog/github.json b/apps/api/forge/connectors/catalog/github.json new file mode 100644 index 0000000..88383d5 --- /dev/null +++ b/apps/api/forge/connectors/catalog/github.json @@ -0,0 +1,120 @@ +{ + "format": "forge.connector/1", + "slug": "github", + "name": "GitHub", + "version": "1.0.0", + "publisher": "forge", + "summary": "Search code, manage issues and pull requests, and read repository activity.", + "categories": ["developer"], + "roles": ["Software Engineer", "Product Manager", "Engineering Manager"], + "icon": "git-branch", + "docs_url": "https://docs.github.com/rest", + "setup_url": "https://github.com/settings/developers", + "auth": { + "kind": "oauth2_authorization_code", + "authorize_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "scopes": ["repo", "read:org", "read:user"], + "per_user": "required", + "credential_group": "github", + "setup_help": "Register one OAuth App at github.com/settings/developers with the callback URL below, then put its Client ID and Client secret in FORGE_CONNECTOR_OAUTH_APPS under \"github\". Everyone signs in as themselves against that one app.", + "setup": [ + { "key": "client_id", "label": "Client ID", "secret": true, "required": true }, + { "key": "client_secret", "label": "Client secret", "secret": true, "required": true } + ] + }, + "egress_hosts": ["api.github.com"], + "backend": { + "type": "rest", + "base_url": "https://api.github.com", + "actions": [ + { + "name": "github_search_issues", + "display_name": "Search issues & PRs", + "description": "Search issues and pull requests with GitHub search syntax, e.g. 'repo:acme/api is:open label:bug'.", + "request": { + "method": "GET", + "url_template": "/search/issues", + "fields": [ + { "path": "q", "in": "query", "type": "string", "required": true, "llm_visible": true, "description": "GitHub issue search query." }, + { "path": "per_page", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 10 } + ], + "headers": [{ "name": "Accept", "value": "application/vnd.github+json" }] + }, + "response": { "projection_jmespath": "items[].{number: number, title: title, state: state, url: html_url, author: user.login, labels: labels[].name}" } + }, + { + "name": "github_get_issue", + "display_name": "Read issue", + "description": "Fetch one issue or pull request, including its body.", + "request": { + "method": "GET", + "url_template": "/repos/{owner}/{repo}/issues/{number}", + "fields": [ + { "path": "owner", "in": "path", "type": "string", "required": true, "llm_visible": true, "description": "Repository owner or organisation." }, + { "path": "repo", "in": "path", "type": "string", "required": true, "llm_visible": true, "description": "Repository name." }, + { "path": "number", "in": "path", "type": "integer", "required": true, "llm_visible": true, "description": "Issue or PR number." } + ], + "headers": [{ "name": "Accept", "value": "application/vnd.github+json" }] + }, + "response": { "projection_jmespath": "{number: number, title: title, state: state, body: body, author: user.login, url: html_url, labels: labels[].name}" } + }, + { + "name": "github_create_issue", + "display_name": "Create issue", + "description": "Open a new issue in a repository.", + "request": { + "method": "POST", + "url_template": "/repos/{owner}/{repo}/issues", + "fields": [ + { "path": "owner", "in": "path", "type": "string", "required": true, "llm_visible": true }, + { "path": "repo", "in": "path", "type": "string", "required": true, "llm_visible": true }, + { "path": "title", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Issue title." }, + { "path": "body", "in": "body", "type": "string", "required": false, "llm_visible": true, "description": "Issue description (markdown)." }, + { "path": "labels", "in": "body", "type": "array", "required": false, "llm_visible": true, "description": "Label names to apply." } + ], + "headers": [{ "name": "Accept", "value": "application/vnd.github+json" }] + }, + "response": { "projection_jmespath": "{number: number, url: html_url}" } + }, + { + "name": "github_comment_on_issue", + "display_name": "Comment on issue", + "description": "Add a comment to an issue or pull request.", + "request": { + "method": "POST", + "url_template": "/repos/{owner}/{repo}/issues/{number}/comments", + "fields": [ + { "path": "owner", "in": "path", "type": "string", "required": true, "llm_visible": true }, + { "path": "repo", "in": "path", "type": "string", "required": true, "llm_visible": true }, + { "path": "number", "in": "path", "type": "integer", "required": true, "llm_visible": true }, + { "path": "body", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Comment text (markdown)." } + ], + "headers": [{ "name": "Accept", "value": "application/vnd.github+json" }] + }, + "response": { "projection_jmespath": "{id: id, url: html_url}" } + }, + { + "name": "github_list_pull_requests", + "display_name": "List pull requests", + "description": "List a repository's pull requests, most recently updated first.", + "request": { + "method": "GET", + "url_template": "/repos/{owner}/{repo}/pulls", + "fields": [ + { "path": "owner", "in": "path", "type": "string", "required": true, "llm_visible": true }, + { "path": "repo", "in": "path", "type": "string", "required": true, "llm_visible": true }, + { "path": "state", "in": "query", "type": "string", "required": false, "llm_visible": true, "default": "open", "description": "open | closed | all." }, + { "path": "sort", "in": "query", "type": "string", "required": false, "llm_visible": false, "default": "updated" }, + { "path": "per_page", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 10 } + ], + "headers": [{ "name": "Accept", "value": "application/vnd.github+json" }] + }, + "response": { "projection_jmespath": "[].{number: number, title: title, state: state, author: user.login, url: html_url, draft: draft}" } + } + ] + }, + "toolset": { + "description": "GitHub: search and read issues and pull requests, open issues, and comment." + } +} diff --git a/apps/api/forge/connectors/catalog/gmail.json b/apps/api/forge/connectors/catalog/gmail.json new file mode 100644 index 0000000..6ad1a84 --- /dev/null +++ b/apps/api/forge/connectors/catalog/gmail.json @@ -0,0 +1,229 @@ +{ + "format": "forge.connector/1", + "slug": "gmail", + "name": "Gmail", + "version": "1.1.0", + "publisher": "forge", + "summary": "Read, search, label, and send email from a Gmail mailbox.", + "categories": [ + "email" + ], + "roles": [ + "Support", + "Sales", + "Operations", + "Executive Assistant" + ], + "icon": "mail", + "docs_url": "https://developers.google.com/gmail/api/reference/rest", + "setup_url": "https://console.cloud.google.com/apis/credentials", + "auth": { + "kind": "oauth2_authorization_code", + "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scopes": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.modify" + ], + "per_user": "required", + "setup_help": "Create an OAuth client (type: Web application) in Google Cloud Console, enable the Gmail API, and add the redirect URI shown above. Each user then connects their own mailbox - a shared Gmail credential would let every end user read one person's inbox.", + "setup": [ + { + "key": "client_id", + "label": "Client ID", + "secret": true, + "required": true, + "placeholder": "....apps.googleusercontent.com" + }, + { + "key": "client_secret", + "label": "Client secret", + "secret": true, + "required": true + } + ], + "credential_group": "google" + }, + "egress_hosts": [ + "gmail.googleapis.com", + "oauth2.googleapis.com", + "accounts.google.com" + ], + "backend": { + "type": "rest", + "base_url": "https://gmail.googleapis.com/gmail/v1/users/me", + "actions": [ + { + "name": "gmail_search_messages", + "display_name": "Search email", + "description": "Search the mailbox using Gmail query syntax (e.g. 'from:acme is:unread newer_than:7d'). Returns message ids to pass to gmail_get_message.", + "request": { + "method": "GET", + "url_template": "/messages", + "fields": [ + { + "path": "q", + "in": "query", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Gmail search query, e.g. 'is:unread from:billing@acme.com'." + }, + { + "path": "maxResults", + "in": "query", + "type": "integer", + "required": false, + "llm_visible": true, + "default": 10, + "description": "How many messages to return (1-50)." + } + ] + }, + "response": { + "projection_jmespath": "messages[].{id: id, thread_id: threadId}" + } + }, + { + "name": "gmail_get_message", + "display_name": "Read email", + "description": "Fetch one message by id: sender, recipient, subject, date and a text snippet.", + "request": { + "method": "GET", + "url_template": "/messages/{message_id}", + "fields": [ + { + "path": "message_id", + "in": "path", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Message id returned by gmail_search_messages." + }, + { + "path": "format", + "in": "query", + "type": "string", + "required": false, + "llm_visible": false, + "default": "metadata" + } + ] + }, + "response": { + "projection_jmespath": "{id: id, snippet: snippet, headers: payload.headers[?name=='From' || name=='To' || name=='Subject' || name=='Date'].{name: name, value: value}}" + } + }, + { + "name": "gmail_send_message", + "display_name": "Send email", + "description": "Send an email as the connected account. Supply the recipient, subject and message text the way you would type them - Forge builds and encodes the MIME message.", + "request": { + "method": "POST", + "url_template": "/messages/send", + "fields": [ + { + "path": "to", + "in": "body", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Recipient address. Comma-separate several." + }, + { + "path": "subject", + "in": "body", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Subject line." + }, + { + "path": "body", + "in": "body", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Message text (plain text; newlines are preserved)." + }, + { + "path": "cc", + "in": "body", + "type": "string", + "required": false, + "llm_visible": true, + "description": "Optional Cc address(es), comma-separated." + }, + { + "path": "bcc", + "in": "body", + "type": "string", + "required": false, + "llm_visible": true, + "description": "Optional Bcc address(es), comma-separated." + } + ], + "body_template": "{\"raw\": {\"$mime\": {\"to\": \"{{input.to}}\", \"cc\": \"{{input.cc}}\", \"bcc\": \"{{input.bcc}}\", \"subject\": \"{{input.subject}}\", \"text\": \"{{input.body}}\"}}}" + }, + "response": { + "projection_jmespath": "{id: id, thread_id: threadId}" + } + }, + { + "name": "gmail_list_labels", + "display_name": "List labels", + "description": "List the mailbox's labels (folders) with their ids, for use with gmail_modify_message.", + "request": { + "method": "GET", + "url_template": "/labels", + "fields": [] + }, + "response": { + "projection_jmespath": "labels[].{id: id, name: name, type: type}" + } + }, + { + "name": "gmail_modify_message", + "display_name": "Label or archive email", + "description": "Add or remove labels on a message. Removing 'INBOX' archives it; removing 'UNREAD' marks it read.", + "request": { + "method": "POST", + "url_template": "/messages/{message_id}/modify", + "fields": [ + { + "path": "message_id", + "in": "path", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Message id." + }, + { + "path": "addLabelIds", + "in": "body", + "type": "array", + "required": false, + "llm_visible": true, + "description": "Label ids to add." + }, + { + "path": "removeLabelIds", + "in": "body", + "type": "array", + "required": false, + "llm_visible": true, + "description": "Label ids to remove." + } + ] + }, + "response": { + "projection_jmespath": "{id: id, label_ids: labelIds}" + } + } + ] + }, + "toolset": { + "description": "Gmail mailbox actions: search, read, send, label and archive email." + } +} diff --git a/apps/api/forge/connectors/catalog/google-calendar.json b/apps/api/forge/connectors/catalog/google-calendar.json new file mode 100644 index 0000000..a47e923 --- /dev/null +++ b/apps/api/forge/connectors/catalog/google-calendar.json @@ -0,0 +1,253 @@ +{ + "format": "forge.connector/1", + "slug": "google-calendar", + "name": "Google Calendar", + "version": "1.0.0", + "publisher": "forge", + "summary": "List, create, and update events on a Google Calendar.", + "categories": [ + "calendar", + "productivity" + ], + "roles": [ + "Executive Assistant", + "Sales", + "Operations", + "Support" + ], + "icon": "calendar", + "docs_url": "https://developers.google.com/calendar/api/v3/reference", + "setup_url": "https://console.cloud.google.com/apis/credentials", + "auth": { + "kind": "oauth2_authorization_code", + "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scopes": [ + "https://www.googleapis.com/auth/calendar" + ], + "per_user": "required", + "setup_help": "Create an OAuth client (Web application) in Google Cloud Console, enable the Google Calendar API, and add the redirect URI shown above.", + "setup": [ + { + "key": "client_id", + "label": "Client ID", + "secret": true, + "required": true, + "placeholder": "....apps.googleusercontent.com" + }, + { + "key": "client_secret", + "label": "Client secret", + "secret": true, + "required": true + } + ], + "credential_group": "google" + }, + "egress_hosts": [ + "www.googleapis.com", + "oauth2.googleapis.com", + "accounts.google.com" + ], + "backend": { + "type": "rest", + "base_url": "https://www.googleapis.com/calendar/v3", + "actions": [ + { + "name": "gcal_list_events", + "display_name": "List events", + "description": "List events on a calendar between two RFC 3339 timestamps, soonest first.", + "request": { + "method": "GET", + "url_template": "/calendars/{calendar_id}/events", + "fields": [ + { + "path": "calendar_id", + "in": "path", + "type": "string", + "required": false, + "llm_visible": true, + "default": "primary", + "description": "Calendar id, or 'primary' for the user's own calendar." + }, + { + "path": "timeMin", + "in": "query", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Window start, RFC 3339 (e.g. 2026-08-13T00:00:00Z)." + }, + { + "path": "timeMax", + "in": "query", + "type": "string", + "required": false, + "llm_visible": true, + "description": "Window end, RFC 3339." + }, + { + "path": "maxResults", + "in": "query", + "type": "integer", + "required": false, + "llm_visible": true, + "default": 20 + }, + { + "path": "singleEvents", + "in": "query", + "type": "boolean", + "required": false, + "llm_visible": false, + "default": true + }, + { + "path": "orderBy", + "in": "query", + "type": "string", + "required": false, + "llm_visible": false, + "default": "startTime" + } + ] + }, + "response": { + "projection_jmespath": "items[].{id: id, summary: summary, start: start.dateTime, end: end.dateTime, attendees: attendees[].email, link: htmlLink}" + } + }, + { + "name": "gcal_create_event", + "display_name": "Create event", + "description": "Create a calendar event. Times are RFC 3339 with an offset (e.g. 2026-08-14T15:00:00+01:00).", + "request": { + "method": "POST", + "url_template": "/calendars/{calendar_id}/events", + "fields": [ + { + "path": "calendar_id", + "in": "path", + "type": "string", + "required": false, + "llm_visible": true, + "default": "primary" + }, + { + "path": "summary", + "in": "body", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Event title." + }, + { + "path": "description", + "in": "body", + "type": "string", + "required": false, + "llm_visible": true, + "description": "Event description." + }, + { + "path": "start", + "in": "body", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Start time, RFC 3339." + }, + { + "path": "end", + "in": "body", + "type": "string", + "required": true, + "llm_visible": true, + "description": "End time, RFC 3339." + }, + { + "path": "attendees", + "in": "body", + "type": "array", + "required": false, + "llm_visible": true, + "description": "Attendee email addresses." + } + ], + "body_template": "{\"summary\":\"{{input.summary}}\",\"description\":\"{{input.description}}\",\"start\":{\"dateTime\":\"{{input.start}}\"},\"end\":{\"dateTime\":\"{{input.end}}\"},\"attendees\":{\"$each\":\"{{input.attendees}}\",\"$as\":\"email\",\"$do\":{\"email\":\"{{email}}\"}}}" + }, + "response": { + "projection_jmespath": "{id: id, link: htmlLink, start: start.dateTime}" + } + }, + { + "name": "gcal_delete_event", + "display_name": "Delete event", + "description": "Delete an event from a calendar.", + "request": { + "method": "DELETE", + "url_template": "/calendars/{calendar_id}/events/{event_id}", + "fields": [ + { + "path": "calendar_id", + "in": "path", + "type": "string", + "required": false, + "llm_visible": true, + "default": "primary" + }, + { + "path": "event_id", + "in": "path", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Event id from gcal_list_events." + } + ] + } + }, + { + "name": "gcal_freebusy", + "display_name": "Check availability", + "description": "Check when one or more calendars are busy in a time window - use this before proposing a meeting slot.", + "request": { + "method": "POST", + "url_template": "/freeBusy", + "fields": [ + { + "path": "timeMin", + "in": "body", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Window start, RFC 3339." + }, + { + "path": "timeMax", + "in": "body", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Window end, RFC 3339." + }, + { + "path": "calendars", + "in": "body", + "type": "array", + "required": true, + "llm_visible": true, + "description": "Calendar ids / email addresses to check." + } + ], + "body_template": "{\"timeMin\":\"{{input.timeMin}}\",\"timeMax\":\"{{input.timeMax}}\",\"items\":{\"$each\":\"{{input.calendars}}\",\"$as\":\"cal\",\"$do\":{\"id\":\"{{cal}}\"}}}" + }, + "response": { + "projection_jmespath": "calendars" + } + } + ] + }, + "toolset": { + "description": "Google Calendar: list and create events, delete events, and check free/busy availability." + } +} diff --git a/apps/api/forge/connectors/catalog/google-drive.json b/apps/api/forge/connectors/catalog/google-drive.json new file mode 100644 index 0000000..87cbd3a --- /dev/null +++ b/apps/api/forge/connectors/catalog/google-drive.json @@ -0,0 +1,155 @@ +{ + "format": "forge.connector/1", + "slug": "google-drive", + "name": "Google Drive", + "version": "1.0.0", + "publisher": "forge", + "summary": "Search Drive files and read document contents as text.", + "categories": [ + "docs", + "storage" + ], + "roles": [ + "Operations", + "Product Manager", + "Marketing", + "Executive Assistant" + ], + "icon": "book-open", + "docs_url": "https://developers.google.com/drive/api/reference/rest/v3", + "setup_url": "https://console.cloud.google.com/apis/credentials", + "auth": { + "kind": "oauth2_authorization_code", + "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scopes": [ + "https://www.googleapis.com/auth/drive.readonly" + ], + "per_user": "required", + "setup_help": "Create an OAuth client (Web application) in Google Cloud Console, enable the Google Drive API, and add the redirect URI shown above. Read-only by default - widen the scope only if the agent needs to write.", + "setup": [ + { + "key": "client_id", + "label": "Client ID", + "secret": true, + "required": true, + "placeholder": "....apps.googleusercontent.com" + }, + { + "key": "client_secret", + "label": "Client secret", + "secret": true, + "required": true + } + ], + "credential_group": "google" + }, + "egress_hosts": [ + "www.googleapis.com", + "oauth2.googleapis.com", + "accounts.google.com" + ], + "backend": { + "type": "rest", + "base_url": "https://www.googleapis.com/drive/v3", + "actions": [ + { + "name": "drive_search_files", + "display_name": "Search files", + "description": "Search Drive. `q` uses Drive query syntax, e.g. \"name contains 'roadmap' and mimeType='application/vnd.google-apps.document'\".", + "request": { + "method": "GET", + "url_template": "/files", + "fields": [ + { + "path": "q", + "in": "query", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Drive search query." + }, + { + "path": "pageSize", + "in": "query", + "type": "integer", + "required": false, + "llm_visible": true, + "default": 15 + }, + { + "path": "fields", + "in": "query", + "type": "string", + "required": false, + "llm_visible": false, + "default": "files(id,name,mimeType,modifiedTime,webViewLink,owners(emailAddress))" + } + ] + }, + "response": { + "projection_jmespath": "files[].{id: id, name: name, type: mimeType, modified: modifiedTime, link: webViewLink}" + } + }, + { + "name": "drive_export_doc", + "display_name": "Read document", + "description": "Export a Google Doc as plain text. Use drive_search_files first to get the file id. Only works for Google-native documents.", + "request": { + "method": "GET", + "url_template": "/files/{file_id}/export", + "fields": [ + { + "path": "file_id", + "in": "path", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Drive file id." + }, + { + "path": "mimeType", + "in": "query", + "type": "string", + "required": false, + "llm_visible": false, + "default": "text/plain" + } + ] + } + }, + { + "name": "drive_get_metadata", + "display_name": "File details", + "description": "Fetch one file's metadata - name, type, owners, sharing link, last modified.", + "request": { + "method": "GET", + "url_template": "/files/{file_id}", + "fields": [ + { + "path": "file_id", + "in": "path", + "type": "string", + "required": true, + "llm_visible": true + }, + { + "path": "fields", + "in": "query", + "type": "string", + "required": false, + "llm_visible": false, + "default": "id,name,mimeType,modifiedTime,size,webViewLink,owners(emailAddress)" + } + ] + }, + "response": { + "projection_jmespath": "{id: id, name: name, type: mimeType, modified: modifiedTime, link: webViewLink, owners: owners[].emailAddress}" + } + } + ] + }, + "toolset": { + "description": "Google Drive: search files, read Google Docs as text, and inspect file metadata." + } +} diff --git a/apps/api/forge/connectors/catalog/google-sheets.json b/apps/api/forge/connectors/catalog/google-sheets.json new file mode 100644 index 0000000..f1bfb0e --- /dev/null +++ b/apps/api/forge/connectors/catalog/google-sheets.json @@ -0,0 +1,182 @@ +{ + "format": "forge.connector/1", + "slug": "google-sheets", + "name": "Google Sheets", + "version": "1.0.0", + "publisher": "forge", + "summary": "Read and append rows in a Google Sheets spreadsheet.", + "categories": [ + "database", + "productivity" + ], + "roles": [ + "Operations", + "Finance", + "Marketing", + "Product Manager" + ], + "icon": "grid", + "docs_url": "https://developers.google.com/sheets/api/reference/rest", + "setup_url": "https://console.cloud.google.com/apis/credentials", + "auth": { + "kind": "oauth2_authorization_code", + "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scopes": [ + "https://www.googleapis.com/auth/spreadsheets" + ], + "per_user": "required", + "setup_help": "Create an OAuth client (Web application) in Google Cloud Console, enable the Google Sheets API, and add the redirect URI shown above.", + "setup": [ + { + "key": "client_id", + "label": "Client ID", + "secret": true, + "required": true, + "placeholder": "....apps.googleusercontent.com" + }, + { + "key": "client_secret", + "label": "Client secret", + "secret": true, + "required": true + } + ], + "credential_group": "google" + }, + "egress_hosts": [ + "sheets.googleapis.com", + "oauth2.googleapis.com", + "accounts.google.com" + ], + "backend": { + "type": "rest", + "base_url": "https://sheets.googleapis.com/v4/spreadsheets", + "actions": [ + { + "name": "sheets_read_range", + "display_name": "Read rows", + "description": "Read a range in A1 notation, e.g. 'Sheet1!A1:D50'. Returns a list of rows.", + "request": { + "method": "GET", + "url_template": "/{spreadsheet_id}/values/{range}", + "fields": [ + { + "path": "spreadsheet_id", + "in": "path", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Spreadsheet id from its URL." + }, + { + "path": "range", + "in": "path", + "type": "string", + "required": true, + "llm_visible": true, + "description": "A1 range, e.g. Sheet1!A1:D50." + } + ] + }, + "response": { + "projection_jmespath": "{range: range, rows: values}" + } + }, + { + "name": "sheets_append_row", + "display_name": "Append row", + "description": "Append one row to the end of a range. `values` is a list of cell values, left to right.", + "request": { + "method": "POST", + "url_template": "/{spreadsheet_id}/values/{range}:append", + "fields": [ + { + "path": "spreadsheet_id", + "in": "path", + "type": "string", + "required": true, + "llm_visible": true + }, + { + "path": "range", + "in": "path", + "type": "string", + "required": true, + "llm_visible": true, + "description": "A1 range to append into, e.g. Sheet1!A:D." + }, + { + "path": "values", + "in": "body", + "type": "array", + "required": true, + "llm_visible": true, + "description": "Cell values for the new row, left to right." + }, + { + "path": "valueInputOption", + "in": "query", + "type": "string", + "required": false, + "llm_visible": false, + "default": "USER_ENTERED" + } + ], + "body_template": "{\"values\":[{{input.values}}]}" + }, + "response": { + "projection_jmespath": "updates.{range: updatedRange, rows: updatedRows}" + } + }, + { + "name": "sheets_update_range", + "display_name": "Update cells", + "description": "Overwrite a range with new values. `rows` is a list of rows, each a list of cell values.", + "request": { + "method": "PUT", + "url_template": "/{spreadsheet_id}/values/{range}", + "fields": [ + { + "path": "spreadsheet_id", + "in": "path", + "type": "string", + "required": true, + "llm_visible": true + }, + { + "path": "range", + "in": "path", + "type": "string", + "required": true, + "llm_visible": true + }, + { + "path": "rows", + "in": "body", + "type": "array", + "required": true, + "llm_visible": true, + "description": "List of rows; each row is a list of cell values." + }, + { + "path": "valueInputOption", + "in": "query", + "type": "string", + "required": false, + "llm_visible": false, + "default": "USER_ENTERED" + } + ], + "body_template": "{\"values\":{{input.rows}}}" + }, + "response": { + "projection_jmespath": "{range: updatedRange, cells: updatedCells}" + } + } + ] + }, + "toolset": { + "description": "Google Sheets: read ranges, append rows, and update cells." + } +} diff --git a/apps/api/forge/connectors/catalog/hubspot.json b/apps/api/forge/connectors/catalog/hubspot.json new file mode 100644 index 0000000..d627968 --- /dev/null +++ b/apps/api/forge/connectors/catalog/hubspot.json @@ -0,0 +1,101 @@ +{ + "format": "forge.connector/1", + "slug": "hubspot", + "name": "HubSpot", + "version": "1.0.0", + "publisher": "forge", + "summary": "Search contacts and companies, read deals, and log CRM notes.", + "categories": ["crm"], + "roles": ["Sales", "Marketing", "Support", "Operations"], + "icon": "users", + "docs_url": "https://developers.hubspot.com/docs/api/crm/contacts", + "setup_url": "https://developers.hubspot.com/docs/api/working-with-oauth", + "auth": { + "kind": "oauth2_authorization_code", + "authorize_url": "https://app.hubspot.com/oauth/authorize", + "token_url": "https://api.hubapi.com/oauth/v1/token", + "scopes": [ + "oauth", + "crm.objects.contacts.read", + "crm.objects.contacts.write", + "crm.objects.companies.read", + "crm.objects.deals.read" + ], + "per_user": "required", + "credential_group": "hubspot", + "setup_help": "Create a HubSpot public app, list the scopes below on it, add the callback URL, then put its Client ID and Client secret in FORGE_CONNECTOR_OAUTH_APPS under \"hubspot\". Each person picks their own HubSpot account when they connect.", + "setup": [ + { "key": "client_id", "label": "Client ID", "secret": true, "required": true }, + { "key": "client_secret", "label": "Client secret", "secret": true, "required": true } + ] + }, + "egress_hosts": ["api.hubapi.com"], + "backend": { + "type": "rest", + "base_url": "https://api.hubapi.com/crm/v3", + "actions": [ + { + "name": "hubspot_search_contacts", + "display_name": "Search contacts", + "description": "Search contacts by free text - an email address, name, or company.", + "request": { + "method": "POST", + "url_template": "/objects/contacts/search", + "fields": [ + { "path": "query", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Search text, e.g. an email address." }, + { "path": "limit", "in": "body", "type": "integer", "required": false, "llm_visible": true, "default": 10 } + ], + "body_template": "{\"query\":\"{{input.query}}\",\"limit\":{{input.limit}},\"properties\":[\"email\",\"firstname\",\"lastname\",\"company\",\"phone\",\"lifecyclestage\"]}" + }, + "response": { "projection_jmespath": "results[].{id: id, email: properties.email, name: join(' ', [properties.firstname, properties.lastname]), company: properties.company, stage: properties.lifecyclestage}" } + }, + { + "name": "hubspot_get_contact", + "display_name": "Read contact", + "description": "Fetch one contact by id, with its standard properties.", + "request": { + "method": "GET", + "url_template": "/objects/contacts/{contact_id}", + "fields": [ + { "path": "contact_id", "in": "path", "type": "string", "required": true, "llm_visible": true, "description": "HubSpot contact id." }, + { "path": "properties", "in": "query", "type": "string", "required": false, "llm_visible": false, "default": "email,firstname,lastname,company,phone,lifecyclestage,hs_lead_status" } + ] + }, + "response": { "projection_jmespath": "{id: id, properties: properties}" } + }, + { + "name": "hubspot_search_deals", + "display_name": "Search deals", + "description": "Search deals by name or associated company.", + "request": { + "method": "POST", + "url_template": "/objects/deals/search", + "fields": [ + { "path": "query", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Search text." }, + { "path": "limit", "in": "body", "type": "integer", "required": false, "llm_visible": true, "default": 10 } + ], + "body_template": "{\"query\":\"{{input.query}}\",\"limit\":{{input.limit}},\"properties\":[\"dealname\",\"amount\",\"dealstage\",\"closedate\",\"pipeline\"]}" + }, + "response": { "projection_jmespath": "results[].{id: id, name: properties.dealname, amount: properties.amount, stage: properties.dealstage, close_date: properties.closedate}" } + }, + { + "name": "hubspot_create_note", + "display_name": "Log a note", + "description": "Create a timeline note. `timestamp` is epoch milliseconds - use the current time unless logging something historical.", + "request": { + "method": "POST", + "url_template": "/objects/notes", + "fields": [ + { "path": "body", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Note text." }, + { "path": "timestamp", "in": "body", "type": "integer", "required": true, "llm_visible": true, "description": "Epoch milliseconds." } + ], + "body_template": "{\"properties\":{\"hs_note_body\":\"{{input.body}}\",\"hs_timestamp\":{{input.timestamp}}}}" + }, + "response": { "projection_jmespath": "{id: id, created: createdAt}" } + } + ] + }, + "toolset": { + "description": "HubSpot CRM: search contacts and deals, read a contact, and log notes." + } +} diff --git a/apps/api/forge/connectors/catalog/linear.json b/apps/api/forge/connectors/catalog/linear.json new file mode 100644 index 0000000..817ffde --- /dev/null +++ b/apps/api/forge/connectors/catalog/linear.json @@ -0,0 +1,32 @@ +{ + "format": "forge.connector/1", + "slug": "linear", + "name": "Linear", + "version": "1.0.0", + "publisher": "forge", + "summary": "Create, search, and update Linear issues, projects, and cycles.", + "categories": ["developer", "project-management"], + "roles": ["Software Engineer", "Product Manager", "Engineering Manager"], + "icon": "bolt", + "docs_url": "https://linear.app/developers", + "setup_url": "https://linear.app/settings/api", + "auth": { + "kind": "oauth2_authorization_code", + "discover": true, + "per_user": "required", + "setup_help": "Linear hosts an official remote MCP server that supports dynamic client registration - connecting usually needs no setup at all.", + "setup": [ + { "key": "client_id", "label": "OAuth client ID", "secret": true, "required": false, "help": "Leave blank to let Forge register automatically." }, + { "key": "client_secret", "label": "OAuth client secret", "secret": true, "required": false } + ] + }, + "egress_hosts": ["mcp.linear.app", "api.linear.app"], + "backend": { + "type": "mcp", + "url": "https://mcp.linear.app/mcp", + "transport": "streamable_http" + }, + "toolset": { + "description": "Linear: search, create, and update issues, projects, and cycles." + } +} diff --git a/apps/api/forge/connectors/catalog/notion.json b/apps/api/forge/connectors/catalog/notion.json new file mode 100644 index 0000000..3bf6e8f --- /dev/null +++ b/apps/api/forge/connectors/catalog/notion.json @@ -0,0 +1,32 @@ +{ + "format": "forge.connector/1", + "slug": "notion", + "name": "Notion", + "version": "1.0.0", + "publisher": "forge", + "summary": "Search the workspace, read pages, and create or update database entries.", + "categories": ["docs", "productivity"], + "roles": ["Product Manager", "Marketing", "Operations", "Software Engineer"], + "icon": "book-open", + "docs_url": "https://developers.notion.com/reference/intro", + "setup_url": "https://www.notion.so/my-integrations", + "auth": { + "kind": "oauth2_authorization_code", + "discover": true, + "per_user": "required", + "setup_help": "Notion hosts an official remote MCP server. Forge registers itself automatically where the server allows it; otherwise create an integration at notion.so/my-integrations and paste its credentials.", + "setup": [ + { "key": "client_id", "label": "OAuth client ID", "secret": true, "required": false, "help": "Leave blank to let Forge register automatically." }, + { "key": "client_secret", "label": "OAuth client secret", "secret": true, "required": false } + ] + }, + "egress_hosts": ["mcp.notion.com", "api.notion.com"], + "backend": { + "type": "mcp", + "url": "https://mcp.notion.com/mcp", + "transport": "streamable_http" + }, + "toolset": { + "description": "Notion workspace: search, read pages and databases, create and update entries." + } +} diff --git a/apps/api/forge/connectors/catalog/outlook.json b/apps/api/forge/connectors/catalog/outlook.json new file mode 100644 index 0000000..d401e6d --- /dev/null +++ b/apps/api/forge/connectors/catalog/outlook.json @@ -0,0 +1,253 @@ +{ + "format": "forge.connector/1", + "slug": "outlook", + "name": "Outlook", + "version": "1.0.0", + "publisher": "forge", + "summary": "Read, search, and send mail from an Outlook / Microsoft 365 mailbox.", + "categories": [ + "email" + ], + "roles": [ + "Support", + "Sales", + "Operations", + "Executive Assistant" + ], + "icon": "mail", + "docs_url": "https://learn.microsoft.com/graph/api/resources/mail-api-overview", + "setup_url": "https://entra.microsoft.com", + "auth": { + "kind": "oauth2_authorization_code", + "authorize_url": "https://login.microsoftonline.com/{setup.tenant}/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/{setup.tenant}/oauth2/v2.0/token", + "scopes": [ + "offline_access", + "Mail.ReadWrite", + "Mail.Send", + "User.Read" + ], + "per_user": "required", + "setup_help": "Register an application in Microsoft Entra ID, add the redirect URI shown above as a Web platform, and grant the delegated Mail.ReadWrite / Mail.Send permissions. Use tenant 'common' for multi-tenant, or your directory (tenant) ID to restrict it to your organisation.", + "setup": [ + { + "key": "tenant", + "label": "Directory (tenant) ID", + "secret": false, + "required": false, + "default": "common", + "help": "'common' works for most setups; use your tenant GUID to lock sign-in to your org." + }, + { + "key": "client_id", + "label": "Application (client) ID", + "secret": true, + "required": true + }, + { + "key": "client_secret", + "label": "Client secret", + "secret": true, + "required": true + } + ], + "credential_group": "microsoft" + }, + "egress_hosts": [ + "graph.microsoft.com", + "login.microsoftonline.com" + ], + "backend": { + "type": "rest", + "base_url": "https://graph.microsoft.com/v1.0/me", + "actions": [ + { + "name": "outlook_search_messages", + "display_name": "Search email", + "description": "Search the mailbox. Use `search` for free-text (e.g. 'invoice overdue') or `filter` for OData filters (e.g. \"isRead eq false\").", + "request": { + "method": "GET", + "url_template": "/messages", + "fields": [ + { + "path": "$search", + "in": "query", + "type": "string", + "required": false, + "llm_visible": true, + "description": "Free-text search across the mailbox. Quote the phrase, e.g. \"invoice\"." + }, + { + "path": "$filter", + "in": "query", + "type": "string", + "required": false, + "llm_visible": true, + "description": "OData filter, e.g. isRead eq false." + }, + { + "path": "$top", + "in": "query", + "type": "integer", + "required": false, + "llm_visible": true, + "default": 10, + "description": "How many messages to return." + }, + { + "path": "$select", + "in": "query", + "type": "string", + "required": false, + "llm_visible": false, + "default": "id,subject,from,receivedDateTime,isRead,bodyPreview" + } + ] + }, + "response": { + "projection_jmespath": "value[].{id: id, subject: subject, from: from.emailAddress.address, received: receivedDateTime, unread: isRead, preview: bodyPreview}" + } + }, + { + "name": "outlook_get_message", + "display_name": "Read email", + "description": "Fetch one message by id, including its plain-text body.", + "request": { + "method": "GET", + "url_template": "/messages/{message_id}", + "fields": [ + { + "path": "message_id", + "in": "path", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Message id from outlook_search_messages." + }, + { + "path": "$select", + "in": "query", + "type": "string", + "required": false, + "llm_visible": false, + "default": "id,subject,from,toRecipients,receivedDateTime,body" + } + ] + }, + "response": { + "projection_jmespath": "{id: id, subject: subject, from: from.emailAddress.address, to: toRecipients[].emailAddress.address, received: receivedDateTime, body: body.content}" + } + }, + { + "name": "outlook_send_message", + "display_name": "Send email", + "description": "Send an email immediately from the connected mailbox.", + "request": { + "method": "POST", + "url_template": "/sendMail", + "fields": [ + { + "path": "to", + "in": "body", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Recipient email address." + }, + { + "path": "subject", + "in": "body", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Subject line." + }, + { + "path": "body", + "in": "body", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Message body (plain text)." + } + ], + "body_template": "{\"message\":{\"subject\":\"{{input.subject}}\",\"body\":{\"contentType\":\"Text\",\"content\":\"{{input.body}}\"},\"toRecipients\":[{\"emailAddress\":{\"address\":\"{{input.to}}\"}}]},\"saveToSentItems\":true}" + }, + "response": { + "projection_jmespath": "{sent: `true`}" + } + }, + { + "name": "outlook_reply_to_message", + "display_name": "Reply to email", + "description": "Reply to an existing message, keeping it on the same thread.", + "request": { + "method": "POST", + "url_template": "/messages/{message_id}/reply", + "fields": [ + { + "path": "message_id", + "in": "path", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Message id to reply to." + }, + { + "path": "comment", + "in": "body", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Reply text." + } + ] + }, + "response": { + "projection_jmespath": "{sent: `true`}" + } + }, + { + "name": "outlook_list_events", + "display_name": "List calendar events", + "description": "List upcoming calendar events between two ISO-8601 timestamps.", + "request": { + "method": "GET", + "url_template": "/calendarView", + "fields": [ + { + "path": "startDateTime", + "in": "query", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Window start, ISO-8601 (e.g. 2026-08-13T00:00:00Z)." + }, + { + "path": "endDateTime", + "in": "query", + "type": "string", + "required": true, + "llm_visible": true, + "description": "Window end, ISO-8601." + }, + { + "path": "$top", + "in": "query", + "type": "integer", + "required": false, + "llm_visible": true, + "default": 20 + } + ] + }, + "response": { + "projection_jmespath": "value[].{id: id, subject: subject, start: start.dateTime, end: end.dateTime, organizer: organizer.emailAddress.address}" + } + } + ] + }, + "toolset": { + "description": "Outlook / Microsoft 365 mail and calendar: search, read, send, reply, and list events." + } +} diff --git a/apps/api/forge/connectors/catalog/slack.json b/apps/api/forge/connectors/catalog/slack.json new file mode 100644 index 0000000..eef11dc --- /dev/null +++ b/apps/api/forge/connectors/catalog/slack.json @@ -0,0 +1,36 @@ +{ + "format": "forge.connector/1", + "slug": "slack", + "name": "Slack", + "version": "1.0.0", + "publisher": "forge", + "summary": "Post messages, search history, and manage channels in a Slack workspace.", + "categories": ["messaging"], + "roles": ["Software Engineer", "Support", "Sales", "Marketing", "Product Manager"], + "icon": "message-square", + "docs_url": "https://api.slack.com/docs", + "setup_url": "https://api.slack.com/apps", + "auth": { + "kind": "oauth2_authorization_code", + "discover": true, + "authorize_url": "https://slack.com/oauth/v2/authorize", + "token_url": "https://slack.com/api/oauth.v2.access", + "scopes": ["chat:write", "channels:read", "channels:history", "search:read", "users:read"], + "per_user": "required", + "setup_help": "Slack hosts an official remote MCP server. Forge tries to register itself automatically; if your workspace requires a pre-created app, make one at api.slack.com/apps, add the redirect URI shown above, and paste its credentials here.", + "setup": [ + { "key": "client_id", "label": "Client ID", "secret": true, "required": false, "help": "Leave blank to let Forge register automatically." }, + { "key": "client_secret", "label": "Client secret", "secret": true, "required": false } + ] + }, + "egress_hosts": ["slack.com", "mcp.slack.com", "api.slack.com"], + "backend": { + "type": "mcp", + "url": "https://mcp.slack.com/mcp", + "transport": "streamable_http", + "deny": ["admin_*"] + }, + "toolset": { + "description": "Slack workspace actions: send and search messages, read channels and users." + } +} diff --git a/apps/api/forge/connectors/examples/custom-rest-api.json b/apps/api/forge/connectors/examples/custom-rest-api.json new file mode 100644 index 0000000..bad0f2b --- /dev/null +++ b/apps/api/forge/connectors/examples/custom-rest-api.json @@ -0,0 +1,44 @@ +{ + "format": "forge.connector/1", + "slug": "custom-rest-api", + "name": "Custom REST API", + "version": "1.0.0", + "publisher": "forge", + "summary": "A starter connector for your own internal API - install it, then edit the actions in the Tool Builder.", + "categories": ["custom"], + "roles": ["Software Engineer"], + "icon": "k_rest", + "docs_url": "https://github.com/", + "auth": { + "kind": "bearer", + "prefix": "Bearer ", + "per_user": "optional", + "setup_help": "Point this at your own service. It installs one GET action you can duplicate and edit in the Tool Builder - or write a full manifest and use Add → Custom connector instead.", + "setup": [ + { "key": "base_url", "label": "API base URL", "secret": false, "required": true, "placeholder": "https://api.internal.acme.com/v1" }, + { "key": "token", "label": "API token", "secret": true, "required": true } + ] + }, + "backend": { + "type": "rest", + "base_url": "{setup.base_url}", + "actions": [ + { + "name": "custom_api_get", + "display_name": "GET request", + "description": "Perform a GET against a path on the configured API. Edit or duplicate this action in the Tool Builder to model your real endpoints.", + "request": { + "method": "GET", + "url_template": "/{path}", + "fields": [ + { "path": "path", "in": "path", "type": "string", "required": true, "llm_visible": true, "description": "Path segment to request, relative to the API base URL." } + ], + "headers": [{ "name": "Accept", "value": "application/json" }] + } + } + ] + }, + "toolset": { + "description": "Your own REST API - edit these actions in the Tool Builder." + } +} diff --git a/apps/api/forge/connectors/examples/discord.json b/apps/api/forge/connectors/examples/discord.json new file mode 100644 index 0000000..a02b5a5 --- /dev/null +++ b/apps/api/forge/connectors/examples/discord.json @@ -0,0 +1,62 @@ +{ + "format": "forge.connector/1", + "slug": "discord", + "name": "Discord", + "version": "1.0.0", + "publisher": "forge", + "summary": "Post messages and read channel history in a Discord server.", + "categories": ["messaging"], + "roles": ["Software Engineer", "Marketing", "Support"], + "icon": "message-square", + "docs_url": "https://discord.com/developers/docs/reference", + "setup_url": "https://discord.com/developers/applications", + "auth": { + "kind": "bearer", + "header_name": "Authorization", + "prefix": "Bot ", + "per_user": "never", + "setup_help": "Create an application at discord.com/developers/applications, add a Bot, copy its token, and invite the bot to your server with the Send Messages and Read Message History permissions.", + "setup": [ + { "key": "token", "label": "Bot token", "secret": true, "required": true } + ] + }, + "egress_hosts": ["discord.com"], + "backend": { + "type": "rest", + "base_url": "https://discord.com/api/v10", + "actions": [ + { + "name": "discord_send_message", + "display_name": "Post message", + "description": "Post a message to a Discord channel.", + "request": { + "method": "POST", + "url_template": "/channels/{channel_id}/messages", + "fields": [ + { "path": "channel_id", "in": "path", "type": "string", "required": true, "llm_visible": true, "description": "Channel id (right-click a channel with developer mode on)." }, + { "path": "content", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Message text (max 2000 characters)." } + ], + "headers": [{ "name": "Content-Type", "value": "application/json" }] + }, + "response": { "projection_jmespath": "{id: id, channel_id: channel_id}" } + }, + { + "name": "discord_channel_messages", + "display_name": "Read channel history", + "description": "Read the most recent messages in a channel, newest first.", + "request": { + "method": "GET", + "url_template": "/channels/{channel_id}/messages", + "fields": [ + { "path": "channel_id", "in": "path", "type": "string", "required": true, "llm_visible": true }, + { "path": "limit", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 20 } + ] + }, + "response": { "projection_jmespath": "[].{id: id, author: author.username, content: content, timestamp: timestamp}" } + } + ] + }, + "toolset": { + "description": "Discord: post messages and read channel history." + } +} diff --git a/apps/api/forge/connectors/examples/jira.json b/apps/api/forge/connectors/examples/jira.json new file mode 100644 index 0000000..0fd7b6c --- /dev/null +++ b/apps/api/forge/connectors/examples/jira.json @@ -0,0 +1,94 @@ +{ + "format": "forge.connector/1", + "slug": "jira", + "name": "Jira (API token)", + "version": "1.0.0", + "publisher": "forge", + "summary": "Search, read, create, and comment on Jira issues with a site API token.", + "categories": ["project-management"], + "roles": ["Software Engineer", "Product Manager", "Engineering Manager", "Support"], + "icon": "validate", + "docs_url": "https://developer.atlassian.com/cloud/jira/platform/rest/v3/", + "setup_url": "https://id.atlassian.com/manage-profile/security/api-tokens", + "auth": { + "kind": "basic", + "per_user": "optional", + "setup_help": "Use this when you want a direct REST connection with an API token rather than the OAuth Atlassian connector - for a service account, or a Data Center site. Username is your Atlassian account email; password is an API token.", + "setup": [ + { "key": "site", "label": "Jira site host", "secret": false, "required": true, "placeholder": "acme.atlassian.net" }, + { "key": "username", "label": "Account email", "secret": true, "required": true }, + { "key": "password", "label": "API token", "secret": true, "required": true } + ] + }, + "egress_hosts": ["atlassian.net"], + "backend": { + "type": "rest", + "base_url": "https://{setup.site}/rest/api/3", + "actions": [ + { + "name": "jira_search_issues", + "display_name": "Search issues", + "description": "Search issues with JQL, e.g. 'project = ENG AND status = \"In Progress\" ORDER BY updated DESC'.", + "request": { + "method": "GET", + "url_template": "/search", + "fields": [ + { "path": "jql", "in": "query", "type": "string", "required": true, "llm_visible": true, "description": "JQL query." }, + { "path": "maxResults", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 15 }, + { "path": "fields", "in": "query", "type": "string", "required": false, "llm_visible": false, "default": "summary,status,assignee,priority,updated" } + ] + }, + "response": { "projection_jmespath": "issues[].{key: key, summary: fields.summary, status: fields.status.name, assignee: fields.assignee.displayName, priority: fields.priority.name, updated: fields.updated}" } + }, + { + "name": "jira_get_issue", + "display_name": "Read issue", + "description": "Fetch one issue by key (e.g. ENG-1234).", + "request": { + "method": "GET", + "url_template": "/issue/{issue_key}", + "fields": [ + { "path": "issue_key", "in": "path", "type": "string", "required": true, "llm_visible": true, "description": "Issue key, e.g. ENG-1234." }, + { "path": "fields", "in": "query", "type": "string", "required": false, "llm_visible": false, "default": "summary,description,status,assignee,priority,labels" } + ] + }, + "response": { "projection_jmespath": "{key: key, summary: fields.summary, status: fields.status.name, assignee: fields.assignee.displayName, labels: fields.labels}" } + }, + { + "name": "jira_create_issue", + "display_name": "Create issue", + "description": "Create an issue in a project. `issue_type` is the name, e.g. 'Task' or 'Bug'.", + "request": { + "method": "POST", + "url_template": "/issue", + "fields": [ + { "path": "project_key", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Project key, e.g. ENG." }, + { "path": "summary", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Issue title." }, + { "path": "description", "in": "body", "type": "string", "required": false, "llm_visible": true, "description": "Issue description (plain text)." }, + { "path": "issue_type", "in": "body", "type": "string", "required": false, "llm_visible": true, "default": "Task", "description": "Issue type name." } + ], + "body_template": "{\"fields\":{\"project\":{\"key\":\"{{input.project_key}}\"},\"summary\":\"{{input.summary}}\",\"issuetype\":{\"name\":\"{{input.issue_type}}\"},\"description\":{\"type\":\"doc\",\"version\":1,\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"{{input.description}}\"}]}]}}}" + }, + "response": { "projection_jmespath": "{key: key, id: id}" } + }, + { + "name": "jira_add_comment", + "display_name": "Comment on issue", + "description": "Add a comment to an issue.", + "request": { + "method": "POST", + "url_template": "/issue/{issue_key}/comment", + "fields": [ + { "path": "issue_key", "in": "path", "type": "string", "required": true, "llm_visible": true }, + { "path": "body", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Comment text." } + ], + "body_template": "{\"body\":{\"type\":\"doc\",\"version\":1,\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"{{input.body}}\"}]}]}}" + }, + "response": { "projection_jmespath": "{id: id, created: created}" } + } + ] + }, + "toolset": { + "description": "Jira: search issues with JQL, read an issue, create issues, and comment." + } +} diff --git a/apps/api/forge/connectors/examples/pagerduty.json b/apps/api/forge/connectors/examples/pagerduty.json new file mode 100644 index 0000000..b1c85ac --- /dev/null +++ b/apps/api/forge/connectors/examples/pagerduty.json @@ -0,0 +1,76 @@ +{ + "format": "forge.connector/1", + "slug": "pagerduty", + "name": "PagerDuty", + "version": "1.0.0", + "publisher": "forge", + "summary": "List and acknowledge incidents, and see who is on call.", + "categories": ["operations", "developer"], + "roles": ["Software Engineer", "Engineering Manager", "Operations"], + "icon": "bolt", + "docs_url": "https://developer.pagerduty.com/api-reference/", + "setup_url": "https://support.pagerduty.com/docs/api-access-keys", + "auth": { + "kind": "api_key", + "location": "header", + "param_name": "Authorization", + "per_user": "never", + "setup_help": "Create a REST API key in PagerDuty. Paste it WITH the 'Token token=' prefix, e.g. 'Token token=u+abc123'.", + "setup": [ + { "key": "api_key", "label": "Authorization header value", "secret": true, "required": true, "placeholder": "Token token=…" } + ] + }, + "egress_hosts": ["api.pagerduty.com"], + "backend": { + "type": "rest", + "base_url": "https://api.pagerduty.com", + "actions": [ + { + "name": "pagerduty_list_incidents", + "display_name": "List incidents", + "description": "List incidents, most urgent first. Filter by status: triggered, acknowledged, or resolved.", + "request": { + "method": "GET", + "url_template": "/incidents", + "fields": [ + { "path": "statuses[]", "in": "query", "type": "string", "required": false, "llm_visible": true, "default": "triggered", "description": "triggered | acknowledged | resolved." }, + { "path": "limit", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 15 } + ], + "headers": [{ "name": "Accept", "value": "application/vnd.pagerduty+json;version=2" }] + }, + "response": { "projection_jmespath": "incidents[].{id: id, number: incident_number, title: title, status: status, urgency: urgency, service: service.summary, created: created_at, url: html_url}" } + }, + { + "name": "pagerduty_get_incident", + "display_name": "Read incident", + "description": "Fetch one incident by id, including its assignments.", + "request": { + "method": "GET", + "url_template": "/incidents/{incident_id}", + "fields": [ + { "path": "incident_id", "in": "path", "type": "string", "required": true, "llm_visible": true, "description": "Incident id." } + ], + "headers": [{ "name": "Accept", "value": "application/vnd.pagerduty+json;version=2" }] + }, + "response": { "projection_jmespath": "incident.{id: id, title: title, status: status, urgency: urgency, description: description, assignees: assignments[].assignee.summary, url: html_url}" } + }, + { + "name": "pagerduty_list_oncalls", + "display_name": "Who is on call", + "description": "List the current on-call assignments across escalation policies.", + "request": { + "method": "GET", + "url_template": "/oncalls", + "fields": [ + { "path": "limit", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 20 } + ], + "headers": [{ "name": "Accept", "value": "application/vnd.pagerduty+json;version=2" }] + }, + "response": { "projection_jmespath": "oncalls[].{user: user.summary, policy: escalation_policy.summary, level: escalation_level, start: start, end: end}" } + } + ] + }, + "toolset": { + "description": "PagerDuty: list and read incidents, and check who is on call." + } +} diff --git a/apps/api/forge/connectors/examples/sendgrid.json b/apps/api/forge/connectors/examples/sendgrid.json new file mode 100644 index 0000000..62b7103 --- /dev/null +++ b/apps/api/forge/connectors/examples/sendgrid.json @@ -0,0 +1,51 @@ +{ + "format": "forge.connector/1", + "slug": "sendgrid", + "name": "SendGrid", + "version": "1.0.0", + "publisher": "forge", + "summary": "Send transactional email through SendGrid.", + "categories": ["email"], + "roles": ["Marketing", "Operations", "Software Engineer"], + "icon": "mail", + "docs_url": "https://www.twilio.com/docs/sendgrid/api-reference", + "setup_url": "https://app.sendgrid.com/settings/api_keys", + "auth": { + "kind": "bearer", + "prefix": "Bearer ", + "per_user": "never", + "setup_help": "Create a restricted API key with only Mail Send permission. The sender address must already be verified in SendGrid.", + "setup": [ + { "key": "token", "label": "API key", "secret": true, "required": true, "placeholder": "SG.…" }, + { "key": "from_email", "label": "Default sender address", "secret": false, "required": true, "placeholder": "noreply@acme.com", "help": "Must be a verified sender in SendGrid." } + ] + }, + "egress_hosts": ["api.sendgrid.com"], + "backend": { + "type": "rest", + "base_url": "https://api.sendgrid.com/v3", + "actions": [ + { + "name": "sendgrid_send_email", + "display_name": "Send email", + "description": "Send a plain-text email to one recipient from the configured sender address.", + "request": { + "method": "POST", + "url_template": "/mail/send", + "fields": [ + { "path": "to", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Recipient email address." }, + { "path": "subject", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Subject line." }, + { "path": "body", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Message body (plain text)." }, + { "path": "from_email", "in": "body", "type": "string", "required": false, "llm_visible": false, "default": "{setup.from_email}" } + ], + "body_template": "{\"personalizations\":[{\"to\":[{\"email\":\"{{input.to}}\"}]}],\"from\":{\"email\":\"{{input.from_email}}\"},\"subject\":\"{{input.subject}}\",\"content\":[{\"type\":\"text/plain\",\"value\":\"{{input.body}}\"}]}", + "headers": [{ "name": "Content-Type", "value": "application/json" }] + }, + "response": { "projection_jmespath": "{sent: `true`}" } + } + ] + }, + "toolset": { + "description": "SendGrid: send transactional email." + } +} diff --git a/apps/api/forge/connectors/examples/shopify.json b/apps/api/forge/connectors/examples/shopify.json new file mode 100644 index 0000000..b7393ea --- /dev/null +++ b/apps/api/forge/connectors/examples/shopify.json @@ -0,0 +1,89 @@ +{ + "format": "forge.connector/1", + "slug": "shopify", + "name": "Shopify", + "version": "1.0.0", + "publisher": "forge", + "summary": "Look up orders, customers, and products in a Shopify store.", + "categories": ["commerce"], + "roles": ["Support", "Operations", "Marketing"], + "icon": "credit-card", + "docs_url": "https://shopify.dev/docs/api/admin-rest", + "auth": { + "kind": "api_key", + "location": "header", + "param_name": "X-Shopify-Access-Token", + "per_user": "never", + "setup_help": "Create a custom app in your Shopify admin (Settings → Apps and sales channels → Develop apps), grant the read_orders / read_customers / read_products scopes, and install it to get an Admin API access token.", + "setup": [ + { "key": "store", "label": "Store domain", "secret": false, "required": true, "placeholder": "acme-store.myshopify.com" }, + { "key": "api_key", "label": "Admin API access token", "secret": true, "required": true, "placeholder": "shpat_…" } + ] + }, + "egress_hosts": ["myshopify.com"], + "backend": { + "type": "rest", + "base_url": "https://{setup.store}/admin/api/2026-01", + "actions": [ + { + "name": "shopify_list_orders", + "display_name": "List orders", + "description": "List recent orders, optionally filtered by status or customer email.", + "request": { + "method": "GET", + "url_template": "/orders.json", + "fields": [ + { "path": "status", "in": "query", "type": "string", "required": false, "llm_visible": true, "default": "any", "description": "open | closed | cancelled | any." }, + { "path": "email", "in": "query", "type": "string", "required": false, "llm_visible": true, "description": "Filter to one customer's email." }, + { "path": "limit", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 10 } + ] + }, + "response": { "projection_jmespath": "orders[].{id: id, number: order_number, email: email, total: total_price, currency: currency, financial_status: financial_status, fulfillment_status: fulfillment_status, created: created_at}" } + }, + { + "name": "shopify_get_order", + "display_name": "Read order", + "description": "Fetch one order by id, including its line items and shipping address.", + "request": { + "method": "GET", + "url_template": "/orders/{order_id}.json", + "fields": [ + { "path": "order_id", "in": "path", "type": "integer", "required": true, "llm_visible": true, "description": "Shopify order id." } + ] + }, + "response": { "projection_jmespath": "order.{id: id, number: order_number, email: email, total: total_price, financial_status: financial_status, fulfillment_status: fulfillment_status, items: line_items[].{title: title, quantity: quantity, price: price}, ship_to: shipping_address.{city: city, country: country}}" } + }, + { + "name": "shopify_search_customers", + "display_name": "Find customer", + "description": "Search customers by email, name, or phone.", + "request": { + "method": "GET", + "url_template": "/customers/search.json", + "fields": [ + { "path": "query", "in": "query", "type": "string", "required": true, "llm_visible": true, "description": "Search text, e.g. an email address." }, + { "path": "limit", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 10 } + ] + }, + "response": { "projection_jmespath": "customers[].{id: id, email: email, name: join(' ', [first_name, last_name]), orders: orders_count, spent: total_spent}" } + }, + { + "name": "shopify_list_products", + "display_name": "List products", + "description": "List products in the store, with their variants and prices.", + "request": { + "method": "GET", + "url_template": "/products.json", + "fields": [ + { "path": "title", "in": "query", "type": "string", "required": false, "llm_visible": true, "description": "Filter by exact product title." }, + { "path": "limit", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 10 } + ] + }, + "response": { "projection_jmespath": "products[].{id: id, title: title, status: status, variants: variants[].{sku: sku, price: price, inventory: inventory_quantity}}" } + } + ] + }, + "toolset": { + "description": "Shopify admin: look up orders, customers, and products." + } +} diff --git a/apps/api/forge/connectors/examples/slack-api.json b/apps/api/forge/connectors/examples/slack-api.json new file mode 100644 index 0000000..9b305cb --- /dev/null +++ b/apps/api/forge/connectors/examples/slack-api.json @@ -0,0 +1,77 @@ +{ + "format": "forge.connector/1", + "slug": "slack-api", + "name": "Slack (bot token)", + "version": "1.0.0", + "publisher": "forge", + "summary": "Post messages and read channel history with a Slack bot token - no MCP server needed.", + "categories": ["messaging"], + "roles": ["Software Engineer", "Support", "Operations"], + "icon": "message-square", + "docs_url": "https://api.slack.com/methods", + "setup_url": "https://api.slack.com/apps", + "auth": { + "kind": "bearer", + "prefix": "Bearer ", + "per_user": "never", + "setup_help": "Use this instead of the OAuth Slack connector when you want a plain bot token, or when the hosted MCP server is not reachable from your network. Create an app at api.slack.com/apps, add the chat:write and channels:history bot scopes, install it, and paste the Bot User OAuth Token.", + "setup": [ + { "key": "token", "label": "Bot user OAuth token", "secret": true, "required": true, "placeholder": "xoxb-…" } + ] + }, + "egress_hosts": ["slack.com"], + "backend": { + "type": "rest", + "base_url": "https://slack.com/api", + "actions": [ + { + "name": "slack_post_message", + "display_name": "Post message", + "description": "Post a message to a channel. Use a channel id (C…) or a #channel name the bot has joined.", + "request": { + "method": "POST", + "url_template": "/chat.postMessage", + "fields": [ + { "path": "channel", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Channel id or #name." }, + { "path": "text", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Message text (Slack mrkdwn)." }, + { "path": "thread_ts", "in": "body", "type": "string", "required": false, "llm_visible": true, "description": "Reply in this thread instead of the channel." } + ], + "headers": [{ "name": "Content-Type", "value": "application/json; charset=utf-8" }] + }, + "response": { "projection_jmespath": "{ok: ok, ts: ts, channel: channel, error: error}" } + }, + { + "name": "slack_list_channels", + "display_name": "List channels", + "description": "List public channels in the workspace with their ids.", + "request": { + "method": "GET", + "url_template": "/conversations.list", + "fields": [ + { "path": "limit", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 100 }, + { "path": "exclude_archived", "in": "query", "type": "boolean", "required": false, "llm_visible": false, "default": true }, + { "path": "types", "in": "query", "type": "string", "required": false, "llm_visible": false, "default": "public_channel" } + ] + }, + "response": { "projection_jmespath": "channels[].{id: id, name: name, members: num_members, topic: topic.value}" } + }, + { + "name": "slack_channel_history", + "display_name": "Read channel history", + "description": "Read recent messages in a channel, newest first.", + "request": { + "method": "GET", + "url_template": "/conversations.history", + "fields": [ + { "path": "channel", "in": "query", "type": "string", "required": true, "llm_visible": true, "description": "Channel id (C…)." }, + { "path": "limit", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 20 } + ] + }, + "response": { "projection_jmespath": "messages[].{ts: ts, user: user, text: text, thread_ts: thread_ts}" } + } + ] + }, + "toolset": { + "description": "Slack via bot token: post messages, list channels, read history." + } +} diff --git a/apps/api/forge/connectors/examples/stripe.json b/apps/api/forge/connectors/examples/stripe.json new file mode 100644 index 0000000..ee412ed --- /dev/null +++ b/apps/api/forge/connectors/examples/stripe.json @@ -0,0 +1,89 @@ +{ + "format": "forge.connector/1", + "slug": "stripe", + "name": "Stripe", + "version": "1.0.0", + "publisher": "forge", + "summary": "Look up customers, payments, subscriptions, and issue refunds.", + "categories": ["payments"], + "roles": ["Support", "Finance", "Operations"], + "icon": "credit-card", + "docs_url": "https://docs.stripe.com/api", + "setup_url": "https://dashboard.stripe.com/apikeys", + "auth": { + "kind": "bearer", + "prefix": "Bearer ", + "per_user": "never", + "setup_help": "Use a restricted API key with only the read (and, if you want refunds, write) permissions the agent needs. A full secret key gives an agent your whole account.", + "setup": [ + { "key": "token", "label": "Secret / restricted API key", "secret": true, "required": true, "placeholder": "rk_live_… or sk_test_…" } + ] + }, + "egress_hosts": ["api.stripe.com"], + "backend": { + "type": "rest", + "base_url": "https://api.stripe.com/v1", + "actions": [ + { + "name": "stripe_search_customers", + "display_name": "Find customer", + "description": "Find a customer by email or name using Stripe search syntax, e.g. \"email:'jane@acme.com'\".", + "request": { + "method": "GET", + "url_template": "/customers/search", + "fields": [ + { "path": "query", "in": "query", "type": "string", "required": true, "llm_visible": true, "description": "Stripe search query, e.g. email:'jane@acme.com'." }, + { "path": "limit", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 5 } + ] + }, + "response": { "projection_jmespath": "data[].{id: id, email: email, name: name, created: created, delinquent: delinquent}" } + }, + { + "name": "stripe_list_charges", + "display_name": "List payments", + "description": "List recent charges, optionally for one customer. Amounts are in the smallest currency unit (cents).", + "request": { + "method": "GET", + "url_template": "/charges", + "fields": [ + { "path": "customer", "in": "query", "type": "string", "required": false, "llm_visible": true, "description": "Customer id (cus_…) to filter by." }, + { "path": "limit", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 10 } + ] + }, + "response": { "projection_jmespath": "data[].{id: id, amount: amount, currency: currency, status: status, paid: paid, refunded: refunded, created: created, description: description}" } + }, + { + "name": "stripe_list_subscriptions", + "display_name": "List subscriptions", + "description": "List a customer's subscriptions and their current status.", + "request": { + "method": "GET", + "url_template": "/subscriptions", + "fields": [ + { "path": "customer", "in": "query", "type": "string", "required": true, "llm_visible": true, "description": "Customer id (cus_…)." }, + { "path": "status", "in": "query", "type": "string", "required": false, "llm_visible": true, "default": "all", "description": "active | past_due | canceled | all." } + ] + }, + "response": { "projection_jmespath": "data[].{id: id, status: status, current_period_end: current_period_end, cancel_at_period_end: cancel_at_period_end, plan: items.data[0].price.nickname}" } + }, + { + "name": "stripe_create_refund", + "display_name": "Refund a payment", + "description": "Refund a charge, fully or partially. Amount is in the smallest currency unit; omit it to refund in full.", + "request": { + "method": "POST", + "url_template": "/refunds", + "fields": [ + { "path": "charge", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Charge id (ch_…) to refund." }, + { "path": "amount", "in": "body", "type": "integer", "required": false, "llm_visible": true, "description": "Partial amount in cents; omit for a full refund." }, + { "path": "reason", "in": "body", "type": "string", "required": false, "llm_visible": true, "description": "duplicate | fraudulent | requested_by_customer." } + ] + }, + "response": { "projection_jmespath": "{id: id, status: status, amount: amount, currency: currency}" } + } + ] + }, + "toolset": { + "description": "Stripe: look up customers, payments and subscriptions, and issue refunds." + } +} diff --git a/apps/api/forge/connectors/examples/twilio.json b/apps/api/forge/connectors/examples/twilio.json new file mode 100644 index 0000000..d129fe2 --- /dev/null +++ b/apps/api/forge/connectors/examples/twilio.json @@ -0,0 +1,64 @@ +{ + "format": "forge.connector/1", + "slug": "twilio", + "name": "Twilio", + "version": "1.0.0", + "publisher": "forge", + "summary": "Send SMS messages and read message history.", + "categories": ["messaging"], + "roles": ["Support", "Operations", "Marketing"], + "icon": "message-square", + "docs_url": "https://www.twilio.com/docs/messaging/api", + "setup_url": "https://console.twilio.com", + "auth": { + "kind": "basic", + "per_user": "never", + "setup_help": "Username is your Account SID, password is the Auth Token (or an API Key SID/Secret pair). Both are on the Twilio console dashboard.", + "setup": [ + { "key": "account_sid", "label": "Account SID", "secret": false, "required": true, "placeholder": "AC…", "help": "Also used in the request URL." }, + { "key": "username", "label": "Account SID or API Key SID", "secret": true, "required": true }, + { "key": "password", "label": "Auth token or API key secret", "secret": true, "required": true }, + { "key": "from_number", "label": "Default sending number", "secret": false, "required": true, "placeholder": "+15551234567" } + ] + }, + "egress_hosts": ["api.twilio.com"], + "backend": { + "type": "rest", + "base_url": "https://api.twilio.com/2010-04-01/Accounts/{setup.account_sid}", + "actions": [ + { + "name": "twilio_send_sms", + "display_name": "Send SMS", + "description": "Send an SMS message. `to` must be in E.164 format, e.g. +15551234567.", + "request": { + "method": "POST", + "url_template": "/Messages.json", + "fields": [ + { "path": "To", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Recipient number in E.164 format." }, + { "path": "Body", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Message text." }, + { "path": "From", "in": "body", "type": "string", "required": false, "llm_visible": false, "default": "{setup.from_number}" } + ] + }, + "response": { "projection_jmespath": "{sid: sid, status: status, to: to}" } + }, + { + "name": "twilio_list_messages", + "display_name": "List messages", + "description": "List recent SMS messages, optionally filtered by the other party's number.", + "request": { + "method": "GET", + "url_template": "/Messages.json", + "fields": [ + { "path": "To", "in": "query", "type": "string", "required": false, "llm_visible": true, "description": "Filter by recipient number." }, + { "path": "From", "in": "query", "type": "string", "required": false, "llm_visible": true, "description": "Filter by sender number." }, + { "path": "PageSize", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 20 } + ] + }, + "response": { "projection_jmespath": "messages[].{sid: sid, from: from, to: to, body: body, status: status, sent: date_sent}" } + } + ] + }, + "toolset": { + "description": "Twilio: send SMS and list message history." + } +} diff --git a/apps/api/forge/connectors/examples/zendesk.json b/apps/api/forge/connectors/examples/zendesk.json new file mode 100644 index 0000000..755e486 --- /dev/null +++ b/apps/api/forge/connectors/examples/zendesk.json @@ -0,0 +1,104 @@ +{ + "format": "forge.connector/1", + "slug": "zendesk", + "name": "Zendesk", + "version": "1.0.0", + "publisher": "forge", + "summary": "Search, read, comment on, and update Zendesk support tickets.", + "categories": ["support"], + "roles": ["Support", "Operations"], + "icon": "inbox", + "docs_url": "https://developer.zendesk.com/api-reference/ticketing/introduction/", + "auth": { + "kind": "basic", + "per_user": "never", + "setup_help": "Enable token access in Zendesk (Admin → Apps and integrations → APIs → Zendesk API). The username is your agent email with '/token' appended, e.g. agent@acme.com/token, and the password is the API token.", + "setup": [ + { "key": "subdomain", "label": "Zendesk subdomain", "secret": false, "required": true, "placeholder": "acme", "help": "The 'acme' in acme.zendesk.com." }, + { "key": "username", "label": "Agent email + /token", "secret": true, "required": true, "placeholder": "agent@acme.com/token" }, + { "key": "password", "label": "API token", "secret": true, "required": true } + ] + }, + "egress_hosts": ["zendesk.com"], + "backend": { + "type": "rest", + "base_url": "https://{setup.subdomain}.zendesk.com/api/v2", + "actions": [ + { + "name": "zendesk_search_tickets", + "display_name": "Search tickets", + "description": "Search tickets with Zendesk search syntax, e.g. 'type:ticket status:open requester:jane@acme.com'.", + "request": { + "method": "GET", + "url_template": "/search.json", + "fields": [ + { "path": "query", "in": "query", "type": "string", "required": true, "llm_visible": true, "description": "Zendesk search query." }, + { "path": "per_page", "in": "query", "type": "integer", "required": false, "llm_visible": true, "default": 10 } + ] + }, + "response": { "projection_jmespath": "results[].{id: id, subject: subject, status: status, priority: priority, requester_id: requester_id, updated: updated_at}" } + }, + { + "name": "zendesk_get_ticket", + "display_name": "Read ticket", + "description": "Fetch one ticket by id, including its description.", + "request": { + "method": "GET", + "url_template": "/tickets/{ticket_id}.json", + "fields": [ + { "path": "ticket_id", "in": "path", "type": "integer", "required": true, "llm_visible": true, "description": "Ticket id." } + ] + }, + "response": { "projection_jmespath": "ticket.{id: id, subject: subject, description: description, status: status, priority: priority, tags: tags}" } + }, + { + "name": "zendesk_list_comments", + "display_name": "Read ticket conversation", + "description": "List the comments on a ticket, oldest first - the full back-and-forth with the customer.", + "request": { + "method": "GET", + "url_template": "/tickets/{ticket_id}/comments.json", + "fields": [ + { "path": "ticket_id", "in": "path", "type": "integer", "required": true, "llm_visible": true } + ] + }, + "response": { "projection_jmespath": "comments[].{author_id: author_id, public: public, created: created_at, body: plain_body}" } + }, + { + "name": "zendesk_add_comment", + "display_name": "Reply to ticket", + "description": "Add a comment to a ticket. Set public=false for an internal note the customer will not see.", + "request": { + "method": "PUT", + "url_template": "/tickets/{ticket_id}.json", + "fields": [ + { "path": "ticket_id", "in": "path", "type": "integer", "required": true, "llm_visible": true }, + { "path": "body", "in": "body", "type": "string", "required": true, "llm_visible": true, "description": "Comment text." }, + { "path": "public", "in": "body", "type": "boolean", "required": false, "llm_visible": true, "default": true, "description": "false = internal note." } + ], + "body_template": "{\"ticket\":{\"comment\":{\"body\":\"{{input.body}}\",\"public\":{{input.public}}}}}" + }, + "response": { "projection_jmespath": "ticket.{id: id, status: status, updated: updated_at}" } + }, + { + "name": "zendesk_update_ticket", + "display_name": "Update ticket", + "description": "Change a ticket's status or priority.", + "request": { + "method": "PUT", + "url_template": "/tickets/{ticket_id}.json", + "fields": [ + { "path": "ticket_id", "in": "path", "type": "integer", "required": true, "llm_visible": true }, + { "path": "status", "in": "body", "type": "string", "required": false, "llm_visible": true, "description": "new | open | pending | hold | solved | closed." }, + { "path": "priority", "in": "body", "type": "string", "required": false, "llm_visible": true, "description": "low | normal | high | urgent." } + ], + "body_template": "{\"ticket\":{\"status\":\"{{input.status}}\",\"priority\":\"{{input.priority}}\"}}" + }, + "response": { "projection_jmespath": "ticket.{id: id, status: status, priority: priority}" } + } + ] + }, + "toolset": { + "description": "Zendesk support: search and read tickets, read conversations, reply, and update status." + } +} diff --git a/apps/api/forge/connectors/install.py b/apps/api/forge/connectors/install.py new file mode 100644 index 0000000..f042571 --- /dev/null +++ b/apps/api/forge/connectors/install.py @@ -0,0 +1,829 @@ +"""Expanding a connector manifest into real Forge rows (and taking them back out again). + +The install is deliberately boring: it calls the SAME services the console calls when a person +builds these by hand (SecretStore, AuthProviderService, ToolSetService, ToolService), so an +installed connector is byte-for-byte the kind of row a user could have authored. There is no +"connector runtime" to diverge from the hand-built path. + +Everything created is recorded on the ConnectorInstall row, which is what makes `uninstall` +exact - it deletes the ids it created and nothing else, so a tool the user later moved into +another tool set, or an auth provider they repointed, is never collateral damage. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from forge.connectors.manifest import ConnectorManifest, McpBackend, RestBackend +from forge.models import AuthProvider, ConnectorInstall, McpClient, Project, Tool, ToolSet +from forge.secrets.store import SecretStore +from forge.services.auth_providers import AuthProviderService +from forge.services.tool_sets import ToolSetService +from forge.services.tools import ToolService + +log = logging.getLogger("forge.connectors") + + +class InstallError(RuntimeError): + """The install cannot proceed (missing credentials, name clash, unreachable server).""" + + +def secret_name(group: str, key: str) -> str: + """Stable, collision-proof name for a connector's stored credential. + + Namespaced by CREDENTIAL GROUP (which defaults to the connector's slug) so two unrelated + connectors can both have a `client_secret` without one overwriting the other's - while + connectors authorised by the same vendor app (all the Google ones) deliberately share. + """ + return f"connector_{group}_{key}" + + +#: The one place a catalog connector's vendor credentials may come from. Named in every error +#: message on this path, because "who types the client secret" is the single question an operator +#: has when a connector says it isn't available. +ENV_VAR = "FORGE_CONNECTOR_OAUTH_APPS" + + +def deployment_app(manifest: ConnectorManifest) -> dict[str, str]: + """Deployment-wide OAuth app credentials for this manifest's group, if the operator + configured one (FORGE_CONNECTOR_OAUTH_APPS). Empty dict = none, so each project registers + its own app.""" + from forge.config import settings + + return dict(settings.connector_oauth_apps.get(manifest.group) or {}) + + +def missing_app_keys(manifest: ConnectorManifest) -> list[str]: + """Credential keys a CATALOG install needs and this deployment hasn't configured. + + Empty for a connector that needs no vendor app at all: `auth.kind: none`, or an MCP server + that publishes OAuth metadata, where Forge registers a client on the fly (RFC 7591) the + first time someone connects. + """ + if manifest.auth.kind == "none" or manifest.auth.discover: + return [] + app = deployment_app(manifest) + return [f.key for f in manifest.auth.setup if f.secret and f.required and not app.get(f.key)] + + +def env_ready(manifest: ConnectorManifest) -> bool: + """True when a catalog connector can be connected with one click, right now. + + Catalog connectors never accept typed credentials. The vendor's OAuth app belongs to the + DEPLOYMENT - it is registered once by whoever runs Forge, in the environment, next to every + other deployment secret - and each person then signs in with their own account against it. + A connector whose app isn't configured is unavailable and says so, rather than falling back + to a form that asks an end user for a client secret they have no business holding. + """ + return not missing_app_keys(manifest) + + +def not_configured_message(manifest: ConnectorManifest) -> str: + """The message an operator needs, not the one the runtime happens to know: what to register, + which env key it goes under, and what the payoff is.""" + import json as _json + + example = _json.dumps({manifest.group: {k: "…" for k in missing_app_keys(manifest)}}) + return ( + f"{manifest.name} isn't set up on this Forge deployment. Whoever runs it registers one " + f"{manifest.group} OAuth app and adds it to {ENV_VAR} (e.g. {example}), then restarts the " + "API. After that everyone signs in with their own account - nothing to paste." + ) + + +async def group_has_credentials( + secrets: SecretStore, tenant_id: str, project_id: str, manifest: ConnectorManifest +) -> bool: + """True when this connector can be installed WITHOUT asking for credentials - either the + deployment ships a registered app for its group, or a sibling connector already set one up.""" + required = [f for f in manifest.auth.setup if f.secret and f.required] + if not required: + return False + app = deployment_app(manifest) + if app and all(app.get(f.key) for f in required): + return True + for field in required: + try: + value = await secrets.read_ref( + tenant_id=tenant_id, project_id=project_id, + ref=f"secret://proj/{secret_name(manifest.group, field.key)}", + ) + except Exception: # noqa: BLE001 - absent secret means the group isn't set up + return False + if not value: + return False + return True + + +def _mcp_tool_allowed(name: str, backend: McpBackend) -> bool: + """Apply the manifest's allow/deny filter to a discovered remote tool name. + + `allow` (when non-empty) is exact and wins outright; otherwise everything passes except + `deny` entries, which support a single trailing `*` so a manifest can drop a whole family + (e.g. "admin_*") without enumerating it.""" + if backend.allow: + return name in backend.allow + for pattern in backend.deny: + if pattern.endswith("*"): + if name.startswith(pattern[:-1]): + return False + elif name == pattern: + return False + return True + + +def _auth_config(manifest: ConnectorManifest, values: dict[str, str], *, per_user: bool) -> dict[str, Any]: + """Build the AuthProvider.config for this manifest + the installer's supplied values. + + Credential VALUES never land here - only `secret://proj/` refs to what was written to + the SecretStore, matching how a hand-built provider stores them.""" + auth = manifest.auth + ref = lambda key: f"secret://proj/{secret_name(manifest.group, key)}" # noqa: E731 - local alias + cfg: dict[str, Any] = {"connector_slug": manifest.slug} + if per_user: + # The dim every per-user surface already keys on (connections router, MCP PAT identity, + # widget context). Keeping this exact string is what makes the existing self-service + # /connections routes work for connectors with no extra wiring. + cfg["per_user_context_keys"] = ["end_user_id"] + + kind = auth.kind + if kind == "bearer": + cfg["token_ref"] = ref("token") + if auth.header_name: + cfg["header_name"] = auth.header_name + if auth.prefix is not None: + cfg["prefix"] = auth.prefix + elif kind == "api_key": + cfg["value_ref"] = ref("api_key") + cfg["in"] = auth.location + cfg["name"] = auth.param_name or auth.header_name or "Authorization" + elif kind == "basic": + cfg["username_ref"] = ref("username") + cfg["password_ref"] = ref("password") + elif kind in ("oauth2_authorization_code", "oauth2_client_credentials"): + cfg["client_id_ref"] = ref("client_id") + cfg["client_secret_ref"] = ref("client_secret") + if auth.token_url: + cfg["token_url"] = auth.token_url + if auth.authorize_url: + cfg["authorize_url"] = auth.authorize_url + if auth.scopes: + cfg["scope"] = " ".join(auth.scopes) + if auth.token_auth != "post": + cfg["token_auth"] = auth.token_auth + if auth.authorize_params: + cfg["authorize_params"] = dict(auth.authorize_params) + if auth.discover: + # Endpoints are filled in at connect time from the MCP server's published metadata. + cfg["oauth_discover"] = True + # Non-secret setup values (a tenant id, a region, an instance host) are exposed to request + # templates as {{env.*}}-style config on the provider AND substituted into URLs below. + extras = {f.key: (values.get(f.key) or f.default or "") for f in auth.setup if not f.secret} + if extras: + cfg["connector_vars"] = extras + # Bake the non-secret values into the OAuth endpoints too: a Microsoft-style + # authorize/token URL embeds the tenant, so leaving `{setup.tenant}` unsubstituted here + # would send the connect flow to a literal, non-existent URL. + for key in ("authorize_url", "token_url"): + if isinstance(cfg.get(key), str): + cfg[key] = _substitute(cfg[key], extras) + return cfg + + +def _substitute(value: Any, vars: dict[str, str]) -> Any: + """Replace `{setup.key}` placeholders in manifest URLs with the installer's non-secret values. + + Uses a distinct `{setup.x}` syntax rather than the runtime `{{...}}` templating on purpose: + these are resolved ONCE at install time and baked into the stored config, so they must not + look like the per-call templates the REST tool renders at run time.""" + if isinstance(value, str): + out = value + for k, v in vars.items(): + out = out.replace("{setup." + k + "}", v) + return out + if isinstance(value, dict): + return {k: _substitute(v, vars) for k, v in value.items()} + if isinstance(value, list): + return [_substitute(v, vars) for v in value] + return value + + +class ConnectorInstaller: + """Install / uninstall / upgrade one connector in one project.""" + + def __init__(self, secrets: SecretStore | None = None) -> None: + self.secrets = secrets or SecretStore() + + # --- queries --------------------------------------------------------------------------- + + @staticmethod + async def list_installs(session: AsyncSession, tenant_id: str, project_id: str) -> list[ConnectorInstall]: + rows = await session.execute( + select(ConnectorInstall) + .where(ConnectorInstall.tenant_id == tenant_id, ConnectorInstall.project_id == project_id) + .order_by(ConnectorInstall.name) + ) + return list(rows.scalars()) + + @staticmethod + async def get_install(session: AsyncSession, tenant_id: str, project_id: str, slug: str) -> ConnectorInstall | None: + row = await session.execute( + select(ConnectorInstall).where( + ConnectorInstall.tenant_id == tenant_id, + ConnectorInstall.project_id == project_id, + ConnectorInstall.slug == slug, + ) + ) + return row.scalar_one_or_none() + + # --- install --------------------------------------------------------------------------- + + async def install( + self, + session: AsyncSession, + tenant_id: str, + project_id: str, + manifest: ConnectorManifest, + *, + values: dict[str, str] | None = None, + auth_mode: str = "shared", + source: str = "catalog", + ) -> ConnectorInstall: + values = dict(values or {}) + existing = await self.get_install(session, tenant_id, project_id, manifest.slug) + if existing is not None: + raise InstallError(f"{manifest.name} is already installed in this project") + + # A CATALOG connector is deployment-managed: its vendor app comes from the environment + # and every account on it is personal. A CUSTOM one is the escape hatch - pasted + # credentials, and the installer's choice of shared vs per-user - because that is where + # a service account or an internal API legitimately belongs. + managed = source == "catalog" + per_user = self._resolve_per_user(manifest, auth_mode, managed=managed) + if managed: + if missing_app_keys(manifest): + raise InstallError(not_configured_message(manifest)) + # Ignore anything the caller sent. There is no supported way to type a credential + # into a catalog connector, so accepting one here would create a second, invisible + # source of truth that `env_ready` would then keep reporting as unconfigured. + values = {} + else: + # A sibling connector in the same credential group may already hold the vendor app's + # credentials (install Gmail, then Google Calendar reuses the same OAuth client), in + # which case this install asks for nothing. + reuse = await group_has_credentials(self.secrets, tenant_id, project_id, manifest) + if not reuse: + self._check_required(manifest, values) + + created_secrets: list[str] = [] + auth_provider_id: str | None = None + tool_set_id: str | None = None + mcp_client_id: str | None = None + tool_ids: list[str] = [] + + try: + # 1. Secrets. Skipped for per-user OAuth where the END USER supplies the token, but a + # per-user OAuth connector still needs the APP's client id/secret, so those are + # written either way when present. + # + # A deployment-registered app (FORGE_CONNECTOR_OAUTH_APPS) fills in anything the + # installer didn't type. Seeding the project's own store - rather than teaching + # the resolver a second credential source - means every downstream path (resolve, + # refresh, rotate, uninstall) stays identical to a hand-pasted credential. + app = deployment_app(manifest) + for field in manifest.auth.setup: + if not field.secret: + continue + val = values.get(field.key, "") or app.get(field.key, "") + if not val: + continue + name = secret_name(manifest.group, field.key) + await self.secrets.write( + session, tenant_id=tenant_id, project_id=project_id, + name=name, value=val, kind="connector", + ) + created_secrets.append(name) + + # 2. Auth provider (unless the connector needs no credentials at all). + if manifest.auth.kind != "none": + cfg = _auth_config(manifest, values, per_user=per_user) + ap = await AuthProviderService.create( + session, tenant_id, project_id, + name=manifest.name, kind=manifest.auth.kind, config=cfg, + ) + auth_provider_id = ap.id + + # 3. Tool set - the folder the user sees, the unit an agent is granted, and the + # GitHub-style toolset published over MCP. One per connector. + ts = await ToolSetService.create( + session, tenant_id, project_id, + name=manifest.name, + description=manifest.toolset.description or manifest.summary, + icon=manifest.icon, + exposed=True, + ) + tool_set_id = ts.id + + # 4. Backend-specific tools. + # Non-secret values, with the manifest's default filling in for anything the + # installer left blank - a `{setup.tenant}` placeholder must never render empty and + # silently produce a malformed URL. + setup_vars = { + f.key: (values.get(f.key) or f.default or "") + for f in manifest.auth.setup if not f.secret + } + if isinstance(manifest.backend, RestBackend): + tool_ids = await self._create_rest_tools( + session, tenant_id, project_id, manifest, ts, auth_provider_id, setup_vars + ) + else: + mcp_client_id, tool_ids = await self._create_mcp_tools( + session, tenant_id, project_id, manifest, ts, auth_provider_id, setup_vars + ) + + # 5. Egress allow-list. A no-op unless the deployment runs a strict allow-list; it + # only ever ADDS the connector's own hosts and never relaxes block_private. + await self._allow_egress(session, tenant_id, project_id, manifest.hosts()) + + row = ConnectorInstall( + tenant_id=tenant_id, project_id=project_id, slug=manifest.slug, name=manifest.name, + version=manifest.version, source=source, manifest=manifest.model_dump(mode="json"), + auth_provider_id=auth_provider_id, tool_set_id=tool_set_id, mcp_client_id=mcp_client_id, + created_tool_ids=tool_ids, created_secret_names=created_secrets, + auth_mode="per_user" if per_user else "shared", + status=self._initial_status(manifest, per_user=per_user), + ) + session.add(row) + await session.commit() + await session.refresh(row) + return row + except Exception: + # A half-installed connector is worse than none: it leaves orphan tools pointing at + # an auth provider the user can't see the origin of. Roll the created rows back. + await self._rollback(session, tenant_id, project_id, tool_ids, tool_set_id, + auth_provider_id, mcp_client_id, created_secrets) + raise + + @staticmethod + def _resolve_per_user(manifest: ConnectorManifest, auth_mode: str, *, managed: bool = False) -> bool: + if manifest.auth.kind == "none": + return False + if managed: + # Every catalog connector is a personal connection, full stop. An account belongs to + # the person who signed in to it, and a project-wide token would mean one person's + # mailbox, drive or repo access silently becomes everyone's. Sharing one credential + # across a project is still possible - it is a deliberate act via a custom connector. + return True + policy = manifest.auth.per_user + if policy == "required": + return True + if policy == "never": + return False + return auth_mode == "per_user" + + @staticmethod + def _check_required(manifest: ConnectorManifest, values: dict[str, str]) -> None: + missing = [ + f.label for f in manifest.auth.setup + if f.required and not (values.get(f.key) or f.default) + ] + if missing: + raise InstallError("Missing required setup values: " + ", ".join(missing)) + + @staticmethod + def _initial_status(manifest: ConnectorManifest, *, per_user: bool) -> str: + if manifest.auth.kind == "none": + return "connected" + if manifest.auth.kind == "oauth2_authorization_code": + # Nobody has run the browser consent yet - true for a shared credential and for a + # per-user one alike. On a per-user connector this row-level status only ever means + # "at least one person has connected"; the status that matters to a given person is + # computed per caller (see routers/connectors.py), because their colleague having + # connected their own mailbox says nothing about theirs. + return "needs_auth" + return "connected" + + # --- backends -------------------------------------------------------------------------- + + async def _create_rest_tools( + self, session: AsyncSession, tenant_id: str, project_id: str, + manifest: ConnectorManifest, ts: ToolSet, auth_provider_id: str | None, setup_vars: dict[str, str], + ) -> list[str]: + backend = manifest.backend + assert isinstance(backend, RestBackend) + ids: list[str] = [] + for action in backend.actions: + cfg = self._rest_tool_config(manifest, backend, action, setup_vars) + tool = await ToolService.create( + session, tenant_id, project_id, + name=action.name, kind="rest_api", config=cfg, auth_provider_id=auth_provider_id, + ) + ids.append(tool.id) + await ToolSetService.add_member(session, ts, tool.id) + return ids + + async def _create_mcp_tools( + self, session: AsyncSession, tenant_id: str, project_id: str, + manifest: ConnectorManifest, ts: ToolSet, auth_provider_id: str | None, setup_vars: dict[str, str], + ) -> tuple[str, list[str]]: + backend = manifest.backend + assert isinstance(backend, McpBackend) + client = McpClient( + tenant_id=tenant_id, project_id=project_id, name=manifest.name, + transport=backend.transport, url=_substitute(backend.url, setup_vars), + args={}, enabled=True, auth_provider_id=auth_provider_id, + ) + session.add(client) + await session.flush() + + # Discovery needs live credentials. Before the OAuth consent has been run there are + # none, so an empty tool list here is EXPECTED, not an error - the connect flow calls + # sync_mcp_tools() afterwards to fill the set in. Failing the install because an + # unauthenticated probe returned nothing would make every OAuth connector uninstallable. + tool_ids = await self._sync_mcp_tools(session, tenant_id, project_id, manifest, client, ts, best_effort=True) + return client.id, tool_ids + + async def _sync_mcp_tools( + self, session: AsyncSession, tenant_id: str, project_id: str, manifest: ConnectorManifest, + client: McpClient, ts: ToolSet, *, best_effort: bool = False, context: dict | None = None, + ) -> list[str]: + """Discover the server's tools and materialize one Tool row per allowed remote tool. + + `context` carries the per-user dims (end_user_id) of whoever's credential should be used + to ASK. It is not optional in practice for a catalog connector: those are all per-user, + so without it the resolver looks up a shared token bundle that was never written, the + request goes out unauthenticated, and the server answers 401 - which is what "0 actions" + after a successful sign-in looks like. + + Idempotent: re-running after a connect (or after the vendor adds tools) creates only the + rows that don't exist yet, so tool ids - and therefore every workflow node and agent + grant that references them - stay stable across a re-sync.""" + from forge.tools.mcp import McpUnavailable, describe_mcp_error, discover_tools + + backend = manifest.backend + assert isinstance(backend, McpBackend) + try: + remote = await discover_tools(client, tenant_id, project_id, context) + except (McpUnavailable, Exception) as e: # noqa: BLE001 - unreachable/unauthed server + detail = describe_mcp_error(e) + if best_effort: + log.info("connector %s: tool discovery deferred (%s)", manifest.slug, detail) + return [] + raise InstallError(f"Could not list tools from {manifest.name}: {detail}") from e + + existing = await session.execute( + select(Tool).where(Tool.tenant_id == tenant_id, Tool.project_id == project_id, Tool.kind == "mcp") + ) + have = { + (t.config or {}).get("remote_tool_name"): t + for t in existing.scalars() + if (t.config or {}).get("mcp_client_id") == client.id + } + + ids: list[str] = [] + for entry in remote: + name = entry.get("name") or "" + if not name or not _mcp_tool_allowed(name, backend): + continue + found = have.get(name) + if found is not None: + ids.append(found.id) + continue + tool = await ToolService.create( + session, tenant_id, project_id, name=name, kind="mcp", + config={ + "name": name, + "kind": "mcp", + "description": entry.get("description", ""), + "mcp_client_id": client.id, + "remote_tool_name": name, + "connector_slug": manifest.slug, + }, + auth_provider_id=client.auth_provider_id, + ) + ids.append(tool.id) + await ToolSetService.add_member(session, ts, tool.id) + return ids + + async def sync_tools(self, session: AsyncSession, install: ConnectorInstall, + *, context: dict | None = None) -> int: + """Bring an installed connector's actions back in line with its manifest. + + For an MCP connector that means re-asking the server what it exposes (after a successful + connect, and from the UI as "Refresh actions"). For a REST connector it means re-applying + the CURRENT catalog manifest to the tool rows - which is how a fix to a bundled connector + actually reaches a project that already installed it, without uninstalling (and losing + everyone's sign-in) to pick it up. + + `context` is whose credential to ask with - `{"end_user_id": ...}` for the per-user + connectors the catalog ships. Callers that have an identity MUST pass it; see + `_sync_mcp_tools` for what happens when nobody does.""" + from forge.connectors.manifest import parse_manifest + + manifest = parse_manifest(install.manifest or {}) + if not isinstance(manifest.backend, McpBackend): + return await self._refresh_rest_tools(session, install, manifest) + if not install.mcp_client_id: + return len(install.created_tool_ids or []) + client = (await session.execute( + select(McpClient).where(McpClient.tenant_id == install.tenant_id, McpClient.id == install.mcp_client_id) + )).scalar_one_or_none() + ts = await ToolSetService.get(session, install.tenant_id, install.tool_set_id or "") + if client is None or ts is None: + return 0 + ids = await self._sync_mcp_tools(session, install.tenant_id, install.project_id, manifest, + client, ts, context=context) + merged = list(dict.fromkeys([*(install.created_tool_ids or []), *ids])) + install.created_tool_ids = merged + await session.commit() + return len(ids) + + async def _refresh_rest_tools(self, session: AsyncSession, install: ConnectorInstall, + frozen: ConnectorManifest) -> int: + """Re-apply a REST connector's manifest to the tool rows it created. + + A catalog connector installed last week holds a COPY of the manifest as it was then. When + a bundled manifest is corrected - as Gmail's send action was, once it turned out that + asking a model to base64-encode a MIME message produces an opaque 400 - the fix has to be + able to reach projects that already installed it. Uninstall/reinstall would work but + deletes the auth provider, so every person who signed in would have to do it again for a + change they had nothing to do with. + + Tool IDS are preserved: rows are matched by action name and updated in place, so every + workflow node and agent grant pointing at them keeps working. Actions the manifest no + longer declares are LEFT ALONE rather than deleted - a workflow may still reference one, + and breaking a live graph is a worse failure than carrying a stale tool. + """ + # The current catalog entry for a bundled connector; the frozen copy for a pasted one, + # which has no newer version to move to. + latest = frozen + if install.source == "catalog": + from forge.connectors import catalog as catalog_mod + + found = catalog_mod.get_manifest(install.slug) + if found is not None: + latest = found + backend = latest.backend + if not isinstance(backend, RestBackend): + return len(install.created_tool_ids or []) + + rows = (await session.execute( + select(Tool).where( + Tool.tenant_id == install.tenant_id, Tool.project_id == install.project_id, + Tool.id.in_(install.created_tool_ids or ["__none__"]), + ) + )).scalars() + by_name = {t.name: t for t in rows} + + ts = await ToolSetService.get(session, install.tenant_id, install.tool_set_id or "") + # The non-secret values THIS install was created with (a Jira site, a custom connector's + # base_url), kept on the auth provider as `connector_vars`. Rebuilding URLs from the + # manifest defaults instead would silently repoint every tool at the wrong host - the + # defaults are what the installer overrode. + setup_vars = {f.key: (f.default or "") for f in latest.auth.setup if not f.secret} + if install.auth_provider_id: + ap = (await session.execute( + select(AuthProvider).where( + AuthProvider.tenant_id == install.tenant_id, + AuthProvider.id == install.auth_provider_id, + ) + )).scalar_one_or_none() + for key, val in ((ap.config or {}).get("connector_vars") or {}).items() if ap else (): + if val: + setup_vars[key] = val + ids = list(install.created_tool_ids or []) + changed = 0 + for action in backend.actions: + cfg = self._rest_tool_config(latest, backend, action, setup_vars) + tool = by_name.get(action.name) + if tool is not None: + if (tool.config or {}) != cfg: + tool.config = cfg + changed += 1 + continue + created = await ToolService.create( + session, install.tenant_id, install.project_id, + name=action.name, kind="rest_api", config=cfg, + auth_provider_id=install.auth_provider_id, + ) + ids.append(created.id) + changed += 1 + if ts is not None: + await ToolSetService.add_member(session, ts, created.id) + + install.created_tool_ids = ids + install.manifest = latest.model_dump(mode="json") + install.version = latest.version + await session.commit() + log.info("connector %s: refreshed %d action(s) to v%s", install.slug, changed, latest.version) + return len(ids) + + @staticmethod + def _rest_tool_config(manifest: ConnectorManifest, backend: RestBackend, action, + setup_vars: dict[str, str]) -> dict[str, Any]: + """The `Tool.config` one REST action expands to. Shared by install and refresh so the two + paths cannot drift into producing different tools from the same manifest.""" + base = _substitute(backend.base_url, setup_vars) + request = _substitute(dict(action.request), setup_vars) + url = str(request.get("url_template", "")) + # A relative action URL composes with the manifest's base_url; an absolute one wins, so a + # single connector can reach a second host (e.g. an upload endpoint) when needed. + if base and not url.startswith(("http://", "https://")): + request["url_template"] = base.rstrip("/") + "/" + url.lstrip("/") + cfg: dict[str, Any] = { + "name": action.name, + "description": action.description, + "kind": "rest_api", + "request": request, + "connector_slug": manifest.slug, + } + if action.display_name: + cfg["display_name"] = action.display_name + if action.response: + cfg["response"] = action.response + for key in ("timeout_seconds", "retry", "rate_limit", "cache"): + val = getattr(action, key) + if val is not None: + cfg[key] = val + return cfg + + # --- egress ---------------------------------------------------------------------------- + + @staticmethod + async def _allow_egress(session: AsyncSession, tenant_id: str, project_id: str, hosts: list[str]) -> None: + if not hosts: + return + project = (await session.execute( + select(Project).where(Project.tenant_id == tenant_id, Project.id == project_id) + )).scalar_one_or_none() + if project is None: + return + cfg = dict(project.config or {}) + egress = dict(cfg.get("egress") or {}) + allow = list(egress.get("allow_hosts") or []) + added = [h for h in hosts if h not in allow] + if not added: + return + egress["allow_hosts"] = [*allow, *added] + cfg["egress"] = egress + project.config = cfg + await session.commit() + + # --- uninstall ------------------------------------------------------------------------- + + async def uninstall(self, session: AsyncSession, install: ConnectorInstall) -> None: + """Remove exactly what this install created. + + Tools are deleted through ToolService so their tool-set memberships go with them. + Agents/workflows that referenced a deleted tool id keep working - the compiler already + skips ids it can't resolve - so uninstalling breaks a graph's capability, never its run. + """ + tenant_id = install.tenant_id + for tool_id in install.created_tool_ids or []: + tool = await ToolService.get(session, tenant_id, tool_id) + if tool is not None: + await ToolService.delete(session, tool) + + if install.mcp_client_id: + client = (await session.execute( + select(McpClient).where(McpClient.tenant_id == tenant_id, McpClient.id == install.mcp_client_id) + )).scalar_one_or_none() + if client is not None: + await session.delete(client) + await session.commit() + from forge.tools.mcp import invalidate_client + invalidate_client(install.mcp_client_id) + + if install.tool_set_id: + ts = await ToolSetService.get(session, tenant_id, install.tool_set_id) + if ts is not None: + await ToolSetService.delete(session, ts) + + if install.auth_provider_id: + ap = (await session.execute( + select(AuthProvider).where(AuthProvider.tenant_id == tenant_id, AuthProvider.id == install.auth_provider_id) + )).scalar_one_or_none() + if ap is not None: + await AuthProviderService.delete(session, ap) + + # Stored credentials are overwritten with an empty value rather than row-deleted, which + # keeps the secret's audit history intact (the store audits reads at one choke point) + # while making the credential unusable immediately. + # + # EXCEPT when a sibling connector still shares this credential group: Gmail, Calendar, + # Drive and Sheets are one Google OAuth app, so blanking the client secret on the way + # out would silently break every other Google connector in the project. + shared = await self._group_still_in_use(session, install) + if shared: + log.info("connector uninstall: keeping %s's shared credentials (still used by %s)", + install.slug, shared) + else: + # Clear the whole GROUP's credentials, not just the names this install happens to + # have recorded. When two connectors share a vendor app only the FIRST one records + # the secret names, so uninstalling that one first (kept, correctly, because a + # sibling remained) and then the sibling would otherwise leave the credential + # orphaned in the store forever with no install left to point at it. + for name in self._group_secret_names(install): + try: + await self.secrets.write( + session, tenant_id=tenant_id, project_id=install.project_id, + name=name, value="", kind="connector", + ) + except Exception as e: # noqa: BLE001 - a missing secret is already the goal state + log.debug("connector uninstall: could not clear secret %s (%s)", name, e) + + await session.delete(install) + await session.commit() + + @staticmethod + def _group_secret_names(install: ConnectorInstall) -> list[str]: + """Every secret name belonging to this install's credential group: the ones it recorded + at install time, plus the ones its manifest declares (which a SIBLING may have written).""" + from forge.connectors.manifest import parse_manifest + + names = list(install.created_secret_names or []) + try: + manifest = parse_manifest(install.manifest or {}) + except Exception: # noqa: BLE001 - fall back to just the recorded names + return names + for field in manifest.auth.setup: + if not field.secret: + continue + name = secret_name(manifest.group, field.key) + if name not in names: + names.append(name) + return names + + @staticmethod + async def _group_still_in_use(session: AsyncSession, install: ConnectorInstall) -> str | None: + """The name of another installed connector sharing this one's credential group, if any. + + Read from each install's FROZEN manifest rather than the live catalog, so the answer + reflects what is actually deployed even if a catalog update later regrouped things.""" + from forge.connectors.manifest import parse_manifest + + try: + mine = parse_manifest(install.manifest or {}) + except Exception: # noqa: BLE001 - an unparseable manifest can't claim a shared group + return None + rows = await session.execute( + select(ConnectorInstall).where( + ConnectorInstall.tenant_id == install.tenant_id, + ConnectorInstall.project_id == install.project_id, + ConnectorInstall.id != install.id, + ) + ) + for other in rows.scalars(): + try: + if parse_manifest(other.manifest or {}).group == mine.group: + return other.name + except Exception: # noqa: BLE001 - skip a sibling we can't parse + continue + return None + + async def _rollback( + self, session: AsyncSession, tenant_id: str, project_id: str, tool_ids: list[str], + tool_set_id: str | None, auth_provider_id: str | None, mcp_client_id: str | None, + secret_names: list[str], + ) -> None: + """Best-effort teardown of a failed partial install. Each step is independently guarded: + the reason we're here is that something already went wrong, so a second failure must not + mask the original exception being re-raised by the caller.""" + try: + await session.rollback() + except Exception: # noqa: BLE001 + pass + stub = ConnectorInstall( + tenant_id=tenant_id, project_id=project_id, slug="__rollback__", name="rollback", + created_tool_ids=tool_ids, created_secret_names=secret_names, + tool_set_id=tool_set_id, auth_provider_id=auth_provider_id, mcp_client_id=mcp_client_id, + ) + try: + # Reuse uninstall's teardown, minus the final row delete (the stub was never added). + for tool_id in stub.created_tool_ids or []: + tool = await ToolService.get(session, tenant_id, tool_id) + if tool is not None: + await ToolService.delete(session, tool) + if mcp_client_id: + client = (await session.execute( + select(McpClient).where(McpClient.tenant_id == tenant_id, McpClient.id == mcp_client_id) + )).scalar_one_or_none() + if client is not None: + await session.delete(client) + await session.commit() + if tool_set_id: + ts = await ToolSetService.get(session, tenant_id, tool_set_id) + if ts is not None: + await ToolSetService.delete(session, ts) + if auth_provider_id: + ap = (await session.execute( + select(AuthProvider).where(AuthProvider.tenant_id == tenant_id, AuthProvider.id == auth_provider_id) + )).scalar_one_or_none() + if ap is not None: + await AuthProviderService.delete(session, ap) + except Exception as e: # noqa: BLE001 + log.warning("connector install rollback was incomplete: %s", e) diff --git a/apps/api/forge/connectors/manifest.py b/apps/api/forge/connectors/manifest.py new file mode 100644 index 0000000..463c99e --- /dev/null +++ b/apps/api/forge/connectors/manifest.py @@ -0,0 +1,299 @@ +"""The `forge.connector/1` manifest - the one format a connector is authored in. + +A manifest declares WHAT to create, never HOW to execute. Two backends cover the field: + +* `mcp` - the vendor runs an MCP server (Slack, Google Workspace, Microsoft 365, Notion, + Linear, GitHub, Atlassian). Forge registers it, discovers its tools, and lets the + vendor own their own API churn. This is the right default in 2026 for any vendor + that ships one. +* `rest` - Forge calls the API directly. Each action is a VERBATIM `Tool.config` payload for + the existing rest_api tool, so there is no second tool format to keep in sync, and + an action can use every feature the Tool Builder already has (templating, response + projection, retry, rate limits, caching). + +Secrets never appear in a manifest. `auth.setup[]` declares which credentials to ASK the +installer for; the values go straight to the SecretStore and the created AuthProvider refers to +them as `secret://` refs - the same rule services/portability.py enforces for bundles. +""" + +from __future__ import annotations + +import re +from typing import Any, Literal + +from pydantic import BaseModel, Field, field_validator, model_validator + +FORMAT = "forge.connector/1" + +_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") +# A tool name is used verbatim as the LLM tool name, so hold it to the provider-safe charset +# every model API accepts (mirrors the component-name rule in services/portability.py). +_TOOL_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") + +# Auth kinds a manifest may declare. Deliberately a SUBSET of AuthResolver's kinds: +# `custom_script` is an audited advanced path and must never be reachable by installing a +# pasted manifest, and `csrf_session` is bespoke enough that it belongs in the Auth Providers +# screen rather than in a shareable catalog entry. +AuthKind = Literal["none", "bearer", "api_key", "basic", "oauth2_authorization_code", "oauth2_client_credentials"] + + +class ManifestError(ValueError): + """A manifest is malformed or declares something an install refuses to create.""" + + +class SetupField(BaseModel): + """One credential the installer must supply (rendered as a form field). + + `secret=True` values are written to the SecretStore and referenced as `secret://proj/`; + non-secret values are substituted into the manifest as plain config (e.g. a tenant id or a + region that forms part of a URL). + """ + + key: str + label: str + help: str | None = None + secret: bool = True + required: bool = True + placeholder: str | None = None + default: str | None = None + + @field_validator("key") + @classmethod + def _key_ok(cls, v: str) -> str: + if not re.fullmatch(r"[a-z0-9_]{1,40}", v or ""): + raise ValueError("setup key must be lowercase alphanumeric/underscore") + return v + + +class AuthSpec(BaseModel): + kind: AuthKind = "none" + # oauth2_* + authorize_url: str | None = None + token_url: str | None = None + scopes: list[str] = Field(default_factory=list) + # How the client authenticates at the token endpoint. "post" (client id/secret in the form + # body) is what most vendors accept; "basic" is the HTTP Basic method RFC 6749 makes + # mandatory for servers and that a few - Airtable among them - accept exclusively. + token_auth: Literal["post", "basic"] = "post" + # Extra authorize-URL parameters a vendor needs and the generic flow has no opinion about + # (Google's access_type/prompt for a refresh token, Slack's user_scope, HubSpot's optional + # scopes). Copied verbatim onto the query string. + authorize_params: dict[str, str] = Field(default_factory=dict) + # api_key / bearer + header_name: str | None = None + prefix: str | None = None + location: Literal["header", "query"] = "header" + param_name: str | None = None + # Whether each end user connects their own account, or the project shares one. + # never - shared only (a service/bot credential) + # optional - installer chooses (default) + # required - per-user only (a personal mailbox: a shared token would be wrong) + per_user: Literal["never", "optional", "required"] = "optional" + setup: list[SetupField] = Field(default_factory=list) + setup_help: str | None = None + # Connectors that are authorised by the SAME vendor OAuth app share a credential group, so + # the app is registered once and every connector in the family reuses it. Gmail, Calendar, + # Drive and Sheets are one Google Cloud OAuth client; Outlook and Teams are one Entra app. + # Without this, a user pastes identical credentials four times and then has four copies to + # rotate. Defaults to the connector's own slug (a family of one). + credential_group: str | None = None + # For MCP-backed connectors whose server advertises OAuth metadata (RFC 9728/8414): let + # Forge discover the endpoints and register a client dynamically (RFC 7591) instead of + # requiring the installer to create an app by hand. Falls back to the static urls above. + discover: bool = False + + @model_validator(mode="after") + def _coherent(self) -> AuthSpec: + if self.kind in ("oauth2_authorization_code", "oauth2_client_credentials"): + # `discover` supplies these at connect time for MCP servers that advertise metadata. + if not self.discover and not self.token_url: + raise ValueError(f"{self.kind} requires token_url (or discover: true)") + if self.kind == "oauth2_authorization_code" and not self.discover and not self.authorize_url: + raise ValueError("oauth2_authorization_code requires authorize_url (or discover: true)") + if self.kind == "api_key" and self.location == "query" and not self.param_name: + raise ValueError("api_key in query requires param_name") + return self + + +class McpBackend(BaseModel): + type: Literal["mcp"] = "mcp" + url: str + transport: Literal["streamable_http", "sse"] = "streamable_http" + # Which of the server's tools to expose. `allow` (if non-empty) is an exact allow-list; + # otherwise everything is exposed except `deny` (supports a trailing `*` wildcard). + allow: list[str] = Field(default_factory=list) + deny: list[str] = Field(default_factory=list) + + @field_validator("url") + @classmethod + def _https(cls, v: str) -> str: + if not v.startswith(("http://", "https://")): + raise ValueError("mcp backend url must be http(s)") + return v + + +class RestAction(BaseModel): + """One REST action == one `rest_api` Tool. + + `request` and `response` are the EXACT structures `tools/rest.py` already consumes and the + Tool Builder already edits - `{method, url_template, fields[], headers[], body_template}` + and `{projection_jmespath}` / `{fields[]}`. They are copied into `Tool.config` untouched. + + Passing them through verbatim rather than inventing a friendlier connector-specific schema + is the point: it means an action gets every capability the Tool Builder has (per-field + `in: query|header|body|cookie`, `llm_visible: false` for server-injected values, `{{ctx.*}}` + templating, `$each` loops, response projection) for free, an installed action can be opened + and edited in the Tool Builder like any hand-built tool, and there is no second request + format to keep in sync with the executor. + """ + + name: str + description: str = "" + display_name: str | None = None + request: dict[str, Any] + response: dict[str, Any] | None = None + # Optional passthroughs to the rest tool's reliability knobs. + timeout_seconds: int | None = None + retry: dict[str, Any] | None = None + rate_limit: dict[str, Any] | None = None + cache: dict[str, Any] | None = None + + @field_validator("name") + @classmethod + def _name_ok(cls, v: str) -> str: + if not _TOOL_NAME_RE.fullmatch(v or ""): + raise ValueError("action name must be 1-64 chars of [A-Za-z0-9_-]") + return v + + @model_validator(mode="after") + def _request_ok(self) -> RestAction: + if not self.request.get("url_template"): + raise ValueError(f"action {self.name!r}: request.url_template is required") + if not self.request.get("method"): + raise ValueError(f"action {self.name!r}: request.method is required") + for field in self.request.get("fields") or []: + if not isinstance(field, dict) or not field.get("path"): + raise ValueError(f"action {self.name!r}: every request field needs a `path`") + where = field.get("in", "query") + if where not in ("query", "header", "body", "cookie", "path"): + raise ValueError(f"action {self.name!r}: field {field['path']!r} has invalid `in`: {where!r}") + return self + + +class RestBackend(BaseModel): + type: Literal["rest"] = "rest" + base_url: str = "" + actions: list[RestAction] = Field(default_factory=list) + + @model_validator(mode="after") + def _has_actions(self) -> RestBackend: + if not self.actions: + raise ValueError("rest backend needs at least one action") + names = [a.name for a in self.actions] + dupes = {n for n in names if names.count(n) > 1} + if dupes: + raise ValueError(f"duplicate action names: {sorted(dupes)}") + return self + + +class ToolSetSpec(BaseModel): + slug: str | None = None + description: str = "" + + +class ConnectorManifest(BaseModel): + format: str = FORMAT + slug: str + name: str + version: str = "1.0.0" + publisher: Literal["forge", "community", "custom"] = "custom" + summary: str = "" + categories: list[str] = Field(default_factory=list) + icon: str | None = None + docs_url: str | None = None + setup_url: str | None = None + # Roles this connector is "popular for" - drives the suggestion row at the top of the + # Connectors screen. Free-form strings matched case-insensitively against the viewer's + # selected role. + roles: list[str] = Field(default_factory=list) + auth: AuthSpec = Field(default_factory=AuthSpec) + # Hosts this connector talks to. Appended to the project's egress allow-list on install so + # a deployment running a strict allow-list doesn't have to be edited by hand. Never used to + # widen anything else - the SSRF guard's default-deny stays in force. + egress_hosts: list[str] = Field(default_factory=list) + backend: McpBackend | RestBackend = Field(discriminator="type") + toolset: ToolSetSpec = Field(default_factory=ToolSetSpec) + + @field_validator("format") + @classmethod + def _format_ok(cls, v: str) -> str: + if v != FORMAT: + raise ValueError(f"unsupported manifest format {v!r} (expected {FORMAT!r})") + return v + + @field_validator("slug") + @classmethod + def _slug_ok(cls, v: str) -> str: + if not _SLUG_RE.fullmatch(v or ""): + raise ValueError("slug must be lowercase [a-z0-9_-], max 64 chars") + return v + + @model_validator(mode="after") + def _auth_backend_coherent(self) -> ConnectorManifest: + # A per-user credential is stored per end user and injected per call. The REST path + # supports that for every auth kind; the MCP path resolves the provider per (user, + # server) connection. Both are fine - what is NOT fine is declaring per_user on a + # connector with no auth at all, which would silently do nothing. + if self.auth.kind == "none" and self.auth.per_user == "required": + raise ValueError("per_user: required is meaningless with auth.kind: none") + return self + + # --- derived helpers ------------------------------------------------------------------ + + @property + def kind_label(self) -> str: + return "MCP" if self.backend.type == "mcp" else "REST" + + @property + def group(self) -> str: + """The credential namespace this connector's secrets live under.""" + return self.auth.credential_group or self.slug + + def hosts(self) -> list[str]: + """Every host this connector needs egress to: the declared list plus the hosts implied + by its own backend/token URLs, so a manifest can't forget to declare its own endpoint.""" + from urllib.parse import urlparse + + out = list(self.egress_hosts) + candidates = [self.auth.token_url, self.auth.authorize_url] + if isinstance(self.backend, McpBackend): + candidates.append(self.backend.url) + else: + candidates.append(self.backend.base_url or None) + for url in candidates: + if not url: + continue + host = urlparse(url).hostname + if host and host not in out: + out.append(host) + return out + + +def parse_manifest(data: dict[str, Any]) -> ConnectorManifest: + """Validate a manifest dict, raising ManifestError with a readable message. + + Pydantic's own error text is verbose and nested; a pasted-manifest form needs one clear + line per problem, so flatten it here rather than at every call site.""" + if not isinstance(data, dict): + raise ManifestError("manifest must be a JSON object") + try: + return ConnectorManifest.model_validate(data) + except Exception as e: # noqa: BLE001 - normalize pydantic/ValueError into one type + errors = getattr(e, "errors", None) + if callable(errors): + lines = [] + for err in e.errors(): # type: ignore[attr-defined] + loc = ".".join(str(p) for p in err.get("loc", ()) if p != "__root__") + lines.append(f"{loc or 'manifest'}: {err.get('msg', 'invalid')}") + raise ManifestError("; ".join(lines[:8])) from e + raise ManifestError(str(e)) from e diff --git a/apps/api/forge/connectors/mcp_auth.py b/apps/api/forge/connectors/mcp_auth.py new file mode 100644 index 0000000..15f3ad8 --- /dev/null +++ b/apps/api/forge/connectors/mcp_auth.py @@ -0,0 +1,164 @@ +"""Forge as an OAuth *client* of a remote MCP server (the MCP authorization spec, client half). + +`routers/mcp_oauth.py` already implements the SERVER half - Forge issuing tokens so Claude +Desktop / Cursor can call Forge's MCP endpoint. This module is its mirror image: Forge +discovering, registering with, and holding tokens for SOMEBODY ELSE'S MCP server, which is what +`mcp.slack.com`, the Google Workspace and Microsoft servers, Notion, Linear and Atlassian all +require. + +Flow, all of it SSRF-guarded: + + 1. GET /.well-known/oauth-protected-resource (RFC 9728) -> authorization_servers[] + 2. GET /.well-known/oauth-authorization-server (RFC 8414) -> authorize/token/register + 3. POST (RFC 7591) -> client_id [+ secret] + 4. authorization-code + PKCE via the existing /v1/oauth/callback + +Steps 1-3 are best-effort: a manifest that declares static `authorize_url`/`token_url` skips +discovery entirely, so a server with no metadata (or a locked-down network) still connects. That +fallback is why `auth.discover` is opt-in per manifest rather than always-on. +""" + +from __future__ import annotations + +import logging +from typing import Any +from urllib.parse import urljoin, urlparse + +from forge.config import settings +from forge.util.http import shared_async_client +from forge.util.ssrf import guarded_request + +log = logging.getLogger("forge.connectors.mcp_auth") + +# Discovery must not stall a connect click. These endpoints are small JSON documents on the +# vendor's own edge; a server that can't answer in this window is one we fall back for. +_DISCOVERY_TIMEOUT = 10.0 + + +class DiscoveryError(RuntimeError): + pass + + +async def _get_json(url: str, *, client=None) -> dict[str, Any] | None: + try: + r = await guarded_request( + client or shared_async_client(), "GET", url, + timeout=_DISCOVERY_TIMEOUT, follow_redirects=True, + headers={"Accept": "application/json", "MCP-Protocol-Version": "2026-07-28"}, + ) + except Exception as e: # noqa: BLE001 - unreachable/blocked/timeout are all "no metadata" + log.debug("oauth discovery: %s failed (%s)", url, e) + return None + if r.status_code >= 400: + return None + try: + body = r.json() + except Exception: # noqa: BLE001 - a non-JSON 200 is not metadata + return None + return body if isinstance(body, dict) else None + + +def _origin(url: str) -> str: + parts = urlparse(url) + return f"{parts.scheme}://{parts.netloc}" + + +async def discover(mcp_url: str, *, client=None) -> dict[str, Any]: + """Resolve a remote MCP server URL to its OAuth endpoints. + + Returns {authorize_url, token_url, registration_url, scopes_supported, issuer} with any + key absent when the server didn't advertise it. Never raises for a server that simply has + no metadata - the caller falls back to the manifest's static endpoints. + """ + out: dict[str, Any] = {} + origin = _origin(mcp_url) + path = urlparse(mcp_url).path.rstrip("/") + + # RFC 9728 permits both a path-scoped and a root-scoped resource document. Try the + # path-scoped one first: a host serving several MCP resources distinguishes them that way. + prm = None + for candidate in ( + f"{origin}/.well-known/oauth-protected-resource{path}" if path else None, + f"{origin}/.well-known/oauth-protected-resource", + ): + if not candidate: + continue + prm = await _get_json(candidate, client=client) + if prm: + break + + as_urls: list[str] = [] + if prm: + servers = prm.get("authorization_servers") or [] + as_urls = [s for s in servers if isinstance(s, str)] + if prm.get("scopes_supported"): + out["scopes_supported"] = prm["scopes_supported"] + if not as_urls: + # No protected-resource document: the MCP server's own origin is the conventional + # fallback issuer, which is what most single-tenant servers implement. + as_urls = [origin] + + for as_url in as_urls: + as_origin = _origin(as_url) + as_path = urlparse(as_url).path.rstrip("/") + for candidate in ( + f"{as_origin}/.well-known/oauth-authorization-server{as_path}" if as_path else None, + f"{as_origin}/.well-known/oauth-authorization-server", + urljoin(as_origin + "/", ".well-known/openid-configuration"), + ): + if not candidate: + continue + meta = await _get_json(candidate, client=client) + if not meta: + continue + if meta.get("authorization_endpoint"): + out["authorize_url"] = meta["authorization_endpoint"] + if meta.get("token_endpoint"): + out["token_url"] = meta["token_endpoint"] + if meta.get("registration_endpoint"): + out["registration_url"] = meta["registration_endpoint"] + if meta.get("issuer"): + out["issuer"] = meta["issuer"] + if meta.get("scopes_supported") and "scopes_supported" not in out: + out["scopes_supported"] = meta["scopes_supported"] + if out.get("authorize_url") and out.get("token_url"): + return out + return out + + +async def register_client( + registration_url: str, *, client_name: str, redirect_uri: str, scopes: list[str] | None = None, client=None +) -> dict[str, Any]: + """Dynamic Client Registration (RFC 7591) against the remote authorization server. + + Requests a CONFIDENTIAL client (`client_secret_post`) because Forge holds the secret + server-side in its own encrypted store - a public client would be the wrong choice here and + would force PKCE to carry the whole burden. Servers that only issue public clients simply + return no `client_secret`, which the token exchange handles. + """ + body = { + "client_name": client_name, + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "client_secret_post", + } + if scopes: + body["scope"] = " ".join(scopes) + r = await guarded_request( + client or shared_async_client(), "POST", registration_url, + json=body, timeout=_DISCOVERY_TIMEOUT, follow_redirects=True, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + if r.status_code >= 400: + raise DiscoveryError(f"client registration failed ({r.status_code}): {r.text[:300]}") + data = r.json() + if not data.get("client_id"): + raise DiscoveryError("client registration returned no client_id") + return data + + +def callback_url() -> str: + """The single redirect URI Forge registers everywhere - the same endpoint the auth-provider + OAuth connect flow already uses, so an operator only ever whitelists one URL per install.""" + return f"{settings.public_base_url.rstrip('/')}/v1/oauth/callback" diff --git a/apps/api/forge/main.py b/apps/api/forge/main.py index cac69a6..eb36b2f 100644 --- a/apps/api/forge/main.py +++ b/apps/api/forge/main.py @@ -29,6 +29,7 @@ channels, components, connections, + connectors, conversations, embed, embed_public, @@ -271,6 +272,7 @@ def create_app() -> FastAPI: knowledge.router, knowledge.qa_router, traces.router, conversations.router, assistant.router, stats.router, triggers_router.router, channels.router, channels.public, handoff.router, evals.router, pricing.router, mcp_oauth.router, mcp_server.router, mcp_tokens.router, mcp_clients.router, versions.router, + connectors.router, ): app.include_router(r) return app diff --git a/apps/api/forge/models/__init__.py b/apps/api/forge/models/__init__.py index 0071d1d..5e8b830 100644 --- a/apps/api/forge/models/__init__.py +++ b/apps/api/forge/models/__init__.py @@ -6,6 +6,7 @@ AuthProvider, Channel, Component, + ConnectorInstall, Dataset, EntityVersion, HandoffRequest, @@ -38,5 +39,5 @@ "Tenant", "User", "Project", "Workflow", "Thread", "Run", "Trace", "Span", "Tool", "ToolSet", "ToolSetMember", "AuthProvider", "Secret", "McpClient", "Agent", "KbSource", "QaPair", "AuditLog", "Trigger", "Channel", "Component", "HandoffRequest", "Dataset", "ModelPrice", "Memory", - "EntityVersion", "EvalRun", "EvalResult", "OAuthClient", + "EntityVersion", "EvalRun", "EvalResult", "OAuthClient", "ConnectorInstall", ] diff --git a/apps/api/forge/models/entities.py b/apps/api/forge/models/entities.py index da5b25a..215115e 100644 --- a/apps/api/forge/models/entities.py +++ b/apps/api/forge/models/entities.py @@ -236,6 +236,12 @@ class McpClient(PkTimestamp, Base): headers_ref: Mapped[str | None] = mapped_column(String(200), nullable=True) enabled: Mapped[bool] = mapped_column(Boolean, default=True) disabled_tools: Mapped[list] = mapped_column(JSON, default=list) # remote tool names toggled off in the External MCP tab + # An Auth Provider whose resolved headers are attached to every request to this server. + # This is what lets Forge talk to an OAuth-protected remote MCP server (mcp.slack.com, + # the Google Workspace / Microsoft servers, Notion, Linear, …) instead of only servers + # that accept a static header secret. Nullable: existing rows and open servers are + # unaffected, and `headers_ref` still works (both are merged, provider wins on conflict). + auth_provider_id: Mapped[str | None] = mapped_column(String(36), nullable=True) class Thread(PkTimestamp, Base): @@ -315,6 +321,29 @@ class Trigger(PkTimestamp, Base): enabled: Mapped[bool] = mapped_column(Boolean, default=True) last_fired_at: Mapped[datetime | None] = mapped_column(nullable=True) status: Mapped[str] = mapped_column(String(20), default="active") + # Two INDEPENDENT axes, deliberately - conflating them is what makes automation ownership + # confusing in most tools: + # + # run_as_user_id - WHOSE connected accounts the run uses. + # scope - WHO the trigger belongs to, and therefore who sees it. + # + # A salesperson's own lead-chaser is `user` scope running as them. A platform team's + # prod-monitor is `project` scope, still running as whoever's accounts it needs. The two vary + # separately: a shared team automation can legitimately act through one person's Slack. + # + # WHOSE connected accounts an unattended run uses. A webhook or a schedule has no signed-in + # person, so without this a per-user connector (every catalog connector is one) has no + # identity to resolve a token for and the run fails at the first tool call. Stamped with the + # editor who saved the workflow the trigger came from, and preserved when a colleague later + # edits that workflow - the automation belongs to whoever connected the account behind it, + # not to whoever last fixed a typo. Reassignable from the Triggers screen. + run_as_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + # "project" (a team automation everyone sees) | "user" (someone's own, listed only for them). + # Defaults to "project" so a pre-existing trigger keeps behaving exactly as it did; new ones + # take their default from the role of whoever saved the workflow (see TriggerService). + # NOTE: this is ownership and visibility, NOT access control on the event itself. A webhook + # URL is a credential - anyone holding it fires the trigger regardless of scope. + scope: Mapped[str] = mapped_column(String(10), default="project") # Runtime state (e.g. app_event dedupe cursor / seen ids); NOT synced from the node. meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict) @@ -521,3 +550,40 @@ class UserSecurity(PkTimestamp, Base): email_verified_at: Mapped[datetime | None] = mapped_column(nullable=True) totp_secret: Mapped[str | None] = mapped_column(String(64), nullable=True) totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + + +class ConnectorInstall(PkTimestamp, Base): + """One installed connector - the receipt for a manifest that was expanded into real rows. + + A connector is NOT a runtime concept: installing one creates an `AuthProvider`, a `ToolSet`, + N `Tool` rows (and an `McpClient` for MCP-backed connectors), and from then on agents, + workflow tool nodes, the MCP server and traces treat them like any other hand-built tool. + Delete `forge/connectors/` and every installed connector keeps working. + + This row exists so the install is REVERSIBLE and UPGRADABLE: it records exactly which rows + the install created (so uninstall removes those and nothing else) and freezes the manifest + that produced them (so a catalog update can be diffed against what's actually deployed + rather than assumed). + """ + + __tablename__ = "connector_installs" + __table_args__ = (UniqueConstraint("tenant_id", "project_id", "slug", name="uq_connector_install_slug"),) + tenant_id: Mapped[str] = mapped_column(String(36), index=True) + project_id: Mapped[str] = mapped_column(String(36), index=True) + slug: Mapped[str] = mapped_column(String(120), index=True) + name: Mapped[str] = mapped_column(String(120)) + version: Mapped[str] = mapped_column(String(40), default="1.0.0") + source: Mapped[str] = mapped_column(String(20), default="catalog") # catalog | custom | url + # Frozen copy of the manifest that produced this install. Survives catalog edits/upgrades, + # so uninstall and "what changed?" stay correct even if the bundled file moved on. + manifest: Mapped[dict] = mapped_column(JSON, default=dict) + auth_provider_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + tool_set_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + mcp_client_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + created_tool_ids: Mapped[list] = mapped_column(JSON, default=list) + created_secret_names: Mapped[list] = mapped_column(JSON, default=list) + # needs_setup (credentials missing) | needs_auth (connect flow not run) | connected | error + status: Mapped[str] = mapped_column(String(20), default="needs_setup") + status_detail: Mapped[str | None] = mapped_column(Text, nullable=True) + # "shared" (one project-wide account) | "per_user" (each end user connects their own). + auth_mode: Mapped[str] = mapped_column(String(20), default="shared") diff --git a/apps/api/forge/routers/connectors.py b/apps/api/forge/routers/connectors.py new file mode 100644 index 0000000..a10419c --- /dev/null +++ b/apps/api/forge/routers/connectors.py @@ -0,0 +1,630 @@ +"""Connectors - browse the catalog, connect your own account, and manage what's installed. + +Three ways in, deliberately: + + * catalog - `POST /{slug}/connect` is the whole flow: it expands the bundled manifest if the + project doesn't have it yet, then hands back the vendor's sign-in URL. + * custom - `POST /custom` installs a pasted/uploaded manifest, so a private connector pack + can live in a company's own git repo instead of a fork of Forge. + * raw MCP - the existing /mcp-clients routes, unchanged. + +Two rules run through every route here: + +CREDENTIALS COME FROM THE DEPLOYMENT. A catalog connector's vendor OAuth app is read from +FORGE_CONNECTOR_OAUTH_APPS and nowhere else - no route on this router accepts one for a catalog +install, and a connector whose group isn't configured reports itself unavailable, naming the env +key, rather than degrading into a form that asks an end user for a client secret. + +ACCOUNTS ARE PERSONAL. Adding a connector creates project-level rows (tools a workflow is built +on), so it stays editor-gated. Connecting is not: each person signs in as themselves, their token +is stored under their own identity, and every status this router reports is answered for the +CALLER - a colleague's mailbox never becomes yours by virtue of sharing a project. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from forge.auth_providers.oauth_flow import ( + OAuthNotConfigured, + build_authorize_url, +) +from forge.auth_providers.oauth_flow import ( + redirect_uri as oauth_redirect_uri, +) +from forge.auth_providers.resolver import AuthResolver +from forge.connectors import catalog as catalog_mod +from forge.connectors.install import ( + ENV_VAR, + ConnectorInstaller, + InstallError, + env_ready, + missing_app_keys, + not_configured_message, + secret_name, +) +from forge.connectors.manifest import ConnectorManifest, ManifestError, McpBackend, parse_manifest +from forge.deps import ( + CurrentUser, + current_tenant_id, + effective_role, + get_current_user, + get_session, + require_role, + role_at_least, +) +from forge.models import AuthProvider, ConnectorInstall, Tool +from forge.secrets.store import SecretNotFound, SecretStore +from forge.services.auth_providers import AuthProviderService + +log = logging.getLogger("forge.connectors") + +router = APIRouter(prefix="/v1/projects/{project_id}/connectors", tags=["connectors"]) + + +# --- payloads ------------------------------------------------------------------------------ + +class InstallIn(BaseModel): + # Credential values keyed by the manifest's setup[].key, for the CUSTOM path only - a + # catalog install ignores both fields (its app comes from the environment and its accounts + # are always personal). Secret values are written to the SecretStore immediately and never + # echoed back by any route on this router. + values: dict[str, str] = Field(default_factory=dict) + auth_mode: str = "shared" # shared | per_user (ignored when the manifest forces one) + + +class CustomInstallIn(InstallIn): + manifest: dict[str, Any] + + +class ConnectIn(BaseModel): + # For a per-user connector an editor may connect ON BEHALF OF a specific end user id; + # omitted means "connect as me", which is what the self-service flow sends. + end_user_id: str | None = None + + +class CredentialsIn(BaseModel): + values: dict[str, str] = Field(default_factory=dict) + + +# --- serialization ------------------------------------------------------------------------- + +def _manifest_out(m: ConnectorManifest, installed: ConnectorInstall | None = None, + *, managed: bool = True, group_ready: bool = False) -> dict: + """Catalog-card shape. Never includes credential values. + + `managed` (every catalog entry) means the vendor app comes from the deployment environment + and there is nothing for anyone to type: the card shows a Connect button, or - when the + operator hasn't registered that vendor yet - says exactly which env keys are missing. + Unmanaged is the custom-manifest path, which still renders `auth.setup` as a form. + """ + ready = env_ready(m) if managed else group_ready + return { + "credential_group": m.group, + "managed": managed, + # Ready to connect right now, with nothing asked of the person clicking. + "configured": ready, + "missing_keys": missing_app_keys(m) if managed else [], + "config_env_key": ENV_VAR, + # The EXACT string sent to the vendor as redirect_uri, so what an operator whitelists is + # what the flow uses. It is derived from FORGE_PUBLIC_BASE_URL (this API's public origin), + # which is not the console's origin - guessing it from the browser produces a URL that + # looks right and fails with redirect_uri_mismatch. + "redirect_uri": oauth_redirect_uri({}), + "slug": m.slug, + "name": m.name, + "version": m.version, + "publisher": m.publisher, + "summary": m.summary, + "categories": m.categories, + "roles": m.roles, + "icon": m.icon, + "docs_url": m.docs_url, + "setup_url": m.setup_url, + "type": m.kind_label, # "MCP" | "REST" + "auth": { + "kind": m.auth.kind, + "per_user": m.auth.per_user, + "scopes": m.auth.scopes, + "setup_help": m.auth.setup_help, + "setup": [f.model_dump() for f in m.auth.setup], + }, + "action_count": ( + len(m.backend.actions) if not isinstance(m.backend, McpBackend) else None + ), + "installed": installed is not None, + "status": installed.status if installed else None, + "install_id": installed.id if installed else None, + } + + +def _install_out(row: ConnectorInstall, *, tool_count: int | None = None) -> dict: + manifest = row.manifest or {} + backend = manifest.get("backend") or {} + return { + "id": row.id, + "slug": row.slug, + "name": row.name, + "version": row.version, + "source": row.source, + "status": row.status, + "status_detail": row.status_detail, + "auth_mode": row.auth_mode, + "auth_kind": (manifest.get("auth") or {}).get("kind", "none"), + "type": "MCP" if backend.get("type") == "mcp" else "REST", + "icon": manifest.get("icon"), + "summary": manifest.get("summary", ""), + "docs_url": manifest.get("docs_url"), + "tool_set_id": row.tool_set_id, + "auth_provider_id": row.auth_provider_id, + "mcp_client_id": row.mcp_client_id, + "tool_count": tool_count if tool_count is not None else len(row.created_tool_ids or []), + "needs_connect": row.status == "needs_auth", + } + + +async def _load_install(session: AsyncSession, tenant_id: str, project_id: str, slug: str) -> ConnectorInstall: + row = await ConnectorInstaller.get_install(session, tenant_id, project_id, slug) + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "connector is not installed in this project") + return row + + +async def _target_end_user(request: Request, user: CurrentUser, requested: str | None) -> str: + """Which end user a per-user connect/disconnect acts on. + + Acting on YOURSELF is open to any real logged-in user (that is the point of self-service + per-user credentials). Acting on someone ELSE is an editor action, checked here rather than + via Depends because it is conditional on the request body.""" + if str(user.id).startswith(("apikey:", "service")): + raise HTTPException(status.HTTP_403_FORBIDDEN, "per-user credentials require a user identity") + target = requested or str(user.id) + if target != str(user.id) and not role_at_least(await effective_role(user, request), "editor"): + raise HTTPException(status.HTTP_403_FORBIDDEN, "connecting on behalf of another user requires role 'editor'") + return target + + +# --- catalog ------------------------------------------------------------------------------- + +@router.get("/catalog") +async def list_catalog(project_id: str, session: AsyncSession = Depends(get_session), + tenant_id: str = Depends(current_tenant_id)): + """The bundled catalog, each entry flagged with whether this project already has it. + + Reads a directory of JSON files - no network call, no third-party service, no API key. This + route works identically on an air-gapped install. Whether an entry is CONNECTABLE is read + from the deployment's environment, which is also free: no per-connector secret lookups just + to paint a page.""" + installs = {i.slug: i for i in await ConnectorInstaller.list_installs(session, tenant_id, project_id)} + return { + "connectors": [_manifest_out(m, installs.get(m.slug)) for m in catalog_mod.list_manifests()], + "categories": catalog_mod.categories(), + "roles": catalog_mod.roles(), + } + + +@router.get("/catalog/{slug}") +async def get_catalog_entry(project_id: str, slug: str, session: AsyncSession = Depends(get_session), + tenant_id: str = Depends(current_tenant_id)): + manifest = catalog_mod.get_manifest(slug) + if manifest is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "connector not found in the catalog") + install = await ConnectorInstaller.get_install(session, tenant_id, project_id, slug) + out = _manifest_out(manifest, install) + if not isinstance(manifest.backend, McpBackend): + out["actions"] = [ + {"name": a.name, "description": a.description, + "method": (a.request or {}).get("method", "GET")} + for a in manifest.backend.actions + ] + else: + out["mcp_url"] = manifest.backend.url + return out + + +# --- installed ----------------------------------------------------------------------------- + +@router.get("") +async def list_installed(project_id: str, session: AsyncSession = Depends(get_session), + tenant_id: str = Depends(current_tenant_id), + user: CurrentUser = Depends(get_current_user)): + rows = await ConnectorInstaller.list_installs(session, tenant_id, project_id) + if not rows: + return [] + # Count only tools that still exist - a user may have deleted one from the Tools screen, + # and reporting the install's original count would then be a lie. + ids = [tid for r in rows for tid in (r.created_tool_ids or [])] + alive: set[str] = set() + if ids: + found = await session.execute( + select(Tool.id).where(Tool.tenant_id == tenant_id, Tool.project_id == project_id, Tool.id.in_(ids)) + ) + alive = {r[0] for r in found.all()} + out = [] + for r in rows: + item = _install_out(r, tool_count=len([t for t in (r.created_tool_ids or []) if t in alive])) + # Per-user connectors have no single "connected" answer, so the list resolves it for the + # CALLER. Without this the gallery would show a green tick to everyone the moment one + # colleague connected their own account, and the person clicking Connect would be told + # they were already done. + item["connected"] = await _connected_for(session, tenant_id, project_id, r, str(user.id)) + out.append(item) + return out + + +async def _connected_for(session: AsyncSession, tenant_id: str, project_id: str, + row: ConnectorInstall, user_id: str) -> bool: + """Whether THIS user can currently act through this connector.""" + if not row.auth_provider_id: + return True + ap = await AuthProviderService.get(session, tenant_id, row.auth_provider_id) + if ap is None: + return False + if row.auth_mode == "per_user": + state = await AuthProviderService.get_user_connection(tenant_id, project_id, ap, user_id) + return bool(state.get("connected")) + try: + bundle = await SecretStore().read_ref( + tenant_id=tenant_id, project_id=project_id, + ref=f"secret://proj/{AuthResolver.bundle_secret_name(ap.id)}", + ) + except SecretNotFound: + # Only OAuth stores a token bundle; for a key/bearer connector the credential itself is + # the connection, so an absent bundle is not "disconnected". + return ap.kind != "oauth2_authorization_code" + return bool(isinstance(bundle, dict) and bundle.get("access_token")) + + +@router.post("/{slug}/install", status_code=201) +async def install_connector(project_id: str, slug: str, body: InstallIn | None = None, + session: AsyncSession = Depends(get_session), + tenant_id: str = Depends(current_tenant_id), + _: CurrentUser = Depends(require_role("editor"))): + """Add a catalog connector without connecting to it yet. + + `POST /{slug}/connect` does this implicitly, which is the path the UI takes. This route is + what you want when adding the connector and signing in are done by different people - an + editor wires up the tools, and everyone else connects their own account afterwards. + """ + manifest = catalog_mod.get_manifest(slug) + if manifest is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "connector not found in the catalog") + try: + row = await ConnectorInstaller().install( + session, tenant_id, project_id, manifest, source="catalog", + ) + except InstallError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + return _install_out(row) + + +@router.post("/custom", status_code=201) +async def install_custom(project_id: str, body: CustomInstallIn, + session: AsyncSession = Depends(get_session), + tenant_id: str = Depends(current_tenant_id), + _: CurrentUser = Depends(require_role("editor"))): + """Install a manifest the user supplied. Same validator, same install path, same rows - + a custom connector is not a second-class citizen, it just isn't in the bundled catalog.""" + try: + manifest = parse_manifest(body.manifest) + except ManifestError as e: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, f"Invalid manifest - {e}") from e + try: + row = await ConnectorInstaller().install( + session, tenant_id, project_id, manifest, + values=body.values, auth_mode=body.auth_mode, source="custom", + ) + except InstallError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + return _install_out(row) + + +@router.get("/examples") +async def list_examples(project_id: str, _: CurrentUser = Depends(require_role("editor"))): + """Ready-made manifests for services that CAN'T be one-click: an API key, a bot token or a + per-tenant subdomain has to come from somewhere, and that somewhere is a person typing it. + + They are offered as starting points for the custom-connector form - open one, adjust it, add + your key - rather than sitting in the gallery pretending to be one click away. + """ + return [ + { + "slug": m.slug, "name": m.name, "summary": m.summary, "icon": m.icon, + "type": m.kind_label, "auth_kind": m.auth.kind, + "needs": [f.label for f in m.auth.setup if f.required], + # The file as authored, so what lands in the form is editable and re-installable. + "manifest": source, + } + for m, source in catalog_mod.list_examples() + ] + + +@router.post("/validate") +async def validate_manifest(project_id: str, body: dict, + _: CurrentUser = Depends(require_role("editor"))): + """Dry-run a manifest so the paste-a-manifest form can show errors before anything is + created. Creates nothing and touches no credentials.""" + try: + manifest = parse_manifest(body.get("manifest") if "manifest" in body else body) + except ManifestError as e: + return {"ok": False, "error": str(e)} + return {"ok": True, "connector": _manifest_out(manifest, managed=False), "hosts": manifest.hosts()} + + +@router.delete("/{slug}", status_code=204) +async def uninstall_connector(project_id: str, slug: str, session: AsyncSession = Depends(get_session), + tenant_id: str = Depends(current_tenant_id), + _: CurrentUser = Depends(require_role("editor"))): + row = await _load_install(session, tenant_id, project_id, slug) + await ConnectorInstaller().uninstall(session, row) + + +# --- credentials + connect ----------------------------------------------------------------- + +@router.put("/{slug}/credentials", status_code=204) +async def set_credentials(project_id: str, slug: str, body: CredentialsIn, + session: AsyncSession = Depends(get_session), + tenant_id: str = Depends(current_tenant_id), + _: CurrentUser = Depends(require_role("editor"))): + """Update the connector's stored setup values (rotate a client secret, fix a typo'd key). + + Only keys the manifest declares are accepted; an unknown key is ignored rather than written, + so this route can never be used to plant an arbitrary secret under a connector's namespace. + """ + row = await _load_install(session, tenant_id, project_id, slug) + if row.source == "catalog": + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + f"{row.name}'s credentials belong to the deployment, not the project - rotate the " + f"{ENV_VAR} entry and restart the API. Everyone's own sign-in survives the rotation.", + ) + manifest = parse_manifest(row.manifest or {}) + declared = {f.key: f for f in manifest.auth.setup} + store = SecretStore() + written = list(row.created_secret_names or []) + for key, value in body.values.items(): + field = declared.get(key) + if field is None or not field.secret or not value: + continue + name = secret_name(manifest.group, key) + await store.write(session, tenant_id=tenant_id, project_id=project_id, + name=name, value=value, kind="connector") + if name not in written: + written.append(name) + row.created_secret_names = written + if row.status == "needs_setup": + row.status = "needs_auth" if manifest.auth.kind == "oauth2_authorization_code" else "connected" + await session.commit() + + +@router.post("/{slug}/connect") +async def connect_connector(request: Request, project_id: str, slug: str, body: ConnectIn | None = None, + session: AsyncSession = Depends(get_session), + tenant_id: str = Depends(current_tenant_id), + user: CurrentUser = Depends(get_current_user)): + """Begin the OAuth consent for this connector and return the URL to open. + + This is the WHOLE flow from the user's side: one call, one browser round trip, connected. + If the project doesn't have the connector yet it is installed here rather than being a + separate step the person has to know about - "install" is Forge's internal bookkeeping + (create an auth provider, a tool set, a tool per action), not a decision anyone came here + to make. + + For an MCP-backed connector whose manifest opted into discovery, the server's OAuth metadata + is fetched and a client is registered dynamically FIRST - that is what makes a one-click + connect possible against servers that support it, with the static manifest endpoints as the + fallback for those that don't. + """ + row = await ConnectorInstaller.get_install(session, tenant_id, project_id, slug) + if row is None: + row = await _install_on_demand(request, session, tenant_id, project_id, slug, user) + if not row.auth_provider_id: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "this connector needs no connection") + ap = await AuthProviderService.get(session, tenant_id, row.auth_provider_id) + if ap is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "auth provider missing for this connector") + + per_user = bool((ap.config or {}).get("per_user_context_keys")) + if per_user: + target = await _target_end_user(request, user, body.end_user_id if body else None) + context = {"end_user_id": target} + else: + # A SHARED credential is the project's, not the caller's - setting it up is an editor + # action even though connecting your own per-user account is not. + if not role_at_least(await effective_role(user, request), "editor"): + raise HTTPException(status.HTTP_403_FORBIDDEN, "requires role 'editor' or higher") + context = {} + + await _ensure_oauth_endpoints(session, tenant_id, project_id, row, ap) + try: + url = await build_authorize_url(ap, tenant_id=tenant_id, project_id=project_id, context=context) + except OAuthNotConfigured as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + return {"authorize_url": url, "per_user": per_user} + + +async def _install_on_demand(request: Request, session: AsyncSession, tenant_id: str, project_id: str, + slug: str, user: CurrentUser) -> ConnectorInstall: + """Add a catalog connector to the project as part of connecting to it. + + Adding a connector creates project-level rows (tools a workflow can be built on), so it stays + an editor action. A viewer who is first through the door gets told what to ask for rather + than a 404 - and once an editor has added it, every other person's connect is just their own + sign-in. + """ + manifest = catalog_mod.get_manifest(slug) + if manifest is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "connector is not installed in this project") + if not env_ready(manifest): + raise HTTPException(status.HTTP_400_BAD_REQUEST, not_configured_message(manifest)) + if not role_at_least(await effective_role(user, request), "editor"): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + f"{manifest.name} hasn't been added to this project yet. An editor adds it once, then " + "everyone connects their own account.", + ) + try: + return await ConnectorInstaller().install( + session, tenant_id, project_id, manifest, source="catalog", + ) + except InstallError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + + +async def _ensure_oauth_endpoints(session: AsyncSession, tenant_id: str, project_id: str, + row: ConnectorInstall, ap: AuthProvider) -> None: + """Fill in authorize/token endpoints (and a client registration) for a discovery connector. + + Runs at most once per install: the discovered values are written back onto the provider + config, so the second connect is a plain authorize-URL build with no extra round trips. + """ + cfg = dict(ap.config or {}) + if not cfg.get("oauth_discover") or (cfg.get("authorize_url") and cfg.get("token_url")): + return + manifest = parse_manifest(row.manifest or {}) + if not isinstance(manifest.backend, McpBackend): + return + + from forge.connectors import mcp_auth + + found = await mcp_auth.discover(manifest.backend.url) + if found.get("authorize_url"): + cfg["authorize_url"] = found["authorize_url"] + if found.get("token_url"): + cfg["token_url"] = found["token_url"] + if not cfg.get("authorize_url") or not cfg.get("token_url"): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + f"{manifest.name} did not publish OAuth metadata. Add the authorize/token URLs to the " + "connector's Auth Provider, or register an app with the vendor and paste its credentials.", + ) + # RFC 8707 - bind issued tokens to this specific MCP server. + cfg["resource"] = manifest.backend.url + + store = SecretStore() + have_client = False + if cfg.get("client_id_ref"): + try: + have_client = bool(await store.read_ref(tenant_id=tenant_id, project_id=project_id, ref=cfg["client_id_ref"])) + except SecretNotFound: + have_client = False + if not have_client and found.get("registration_url"): + try: + reg = await mcp_auth.register_client( + found["registration_url"], client_name="Forge", + redirect_uri=mcp_auth.callback_url(), + scopes=manifest.auth.scopes or found.get("scopes_supported") or [], + ) + except Exception as e: # noqa: BLE001 - fall through to "paste your own app credentials" + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + f"Could not register with {manifest.name} automatically ({e}). Create an app with the " + "vendor and paste its Client ID/Secret in the connector's settings.", + ) from e + names = list(row.created_secret_names or []) + for key, value in (("client_id", reg.get("client_id")), ("client_secret", reg.get("client_secret"))): + if not value: + continue + name = secret_name(manifest.group, key) + await store.write(session, tenant_id=tenant_id, project_id=project_id, + name=name, value=value, kind="connector") + if name not in names: + names.append(name) + row.created_secret_names = names + cfg["client_id_ref"] = f"secret://proj/{secret_name(manifest.group, 'client_id')}" + cfg["client_secret_ref"] = f"secret://proj/{secret_name(manifest.group, 'client_secret')}" + if not cfg.get("scope") and (manifest.auth.scopes or found.get("scopes_supported")): + cfg["scope"] = " ".join(manifest.auth.scopes or found.get("scopes_supported") or []) + ap.config = cfg + await session.commit() + + +@router.get("/{slug}/status") +async def connector_status(project_id: str, slug: str, session: AsyncSession = Depends(get_session), + tenant_id: str = Depends(current_tenant_id), + user: CurrentUser = Depends(get_current_user)): + """Whether this connector is usable right now - and for a per-user connector, whether the + CALLER personally has connected it (which is the only status that matters to them).""" + row = await _load_install(session, tenant_id, project_id, slug) + out = _install_out(row) + if not row.auth_provider_id: + out["connected"] = True + return out + ap = await AuthProviderService.get(session, tenant_id, row.auth_provider_id) + if ap is None: + out["connected"] = False + return out + if row.auth_mode == "per_user": + state = await AuthProviderService.get_user_connection(tenant_id, project_id, ap, str(user.id)) + out["connected"] = bool(state.get("connected")) + out["expires_at"] = state.get("expires_at") + return out + try: + bundle = await SecretStore().read_ref( + tenant_id=tenant_id, project_id=project_id, + ref=f"secret://proj/{AuthResolver.bundle_secret_name(ap.id)}", + ) + except SecretNotFound: + # Only OAuth stores a token bundle; for a key/bearer connector the credential itself is + # the connection, so an absent bundle is not "disconnected". + out["connected"] = (ap.kind != "oauth2_authorization_code") + return out + out["connected"] = bool(isinstance(bundle, dict) and bundle.get("access_token")) + out["expires_at"] = bundle.get("expires_at") if isinstance(bundle, dict) else None + return out + + +@router.post("/{slug}/sync") +async def sync_connector(project_id: str, slug: str, session: AsyncSession = Depends(get_session), + tenant_id: str = Depends(current_tenant_id), + user: CurrentUser = Depends(get_current_user)): + """Re-discover an MCP connector's actions (after connecting, or when the vendor ships new + tools). Existing tool rows are reused, so workflow nodes and agent grants keep their ids. + + Asks the server with the CALLER's credential, because that is the only one a per-user + connector has. Open to anyone who can connect rather than editor-only: the rows it creates + are entirely determined by what the vendor advertises for a connector an editor already + added, and gating it would leave whoever actually signed in staring at zero actions. + """ + row = await _load_install(session, tenant_id, project_id, slug) + context = {"end_user_id": str(user.id)} if row.auth_mode == "per_user" else None + try: + count = await ConnectorInstaller().sync_tools(session, row, context=context) + except Exception as e: # noqa: BLE001 - report, don't 500: the usual cause is "not connected yet" + from forge.tools.mcp import describe_mcp_error + + return {"ok": False, "error": describe_mcp_error(e)} + return {"ok": True, "tool_count": count} + + +@router.post("/{slug}/disconnect", status_code=204) +async def disconnect_connector(request: Request, project_id: str, slug: str, body: ConnectIn | None = None, + session: AsyncSession = Depends(get_session), + tenant_id: str = Depends(current_tenant_id), + user: CurrentUser = Depends(get_current_user)): + """Revoke the stored token WITHOUT uninstalling: the tools stay wired into workflows and + agents, they just stop being able to act until reconnected. Uninstall is the destructive one.""" + row = await _load_install(session, tenant_id, project_id, slug) + if not row.auth_provider_id: + return + ap = await AuthProviderService.get(session, tenant_id, row.auth_provider_id) + if ap is None: + return + if row.auth_mode == "per_user": + target = await _target_end_user(request, user, body.end_user_id if body else None) + await AuthProviderService.clear_user_connection(session, tenant_id, project_id, ap, target) + return + if not role_at_least(await effective_role(user, request), "editor"): + raise HTTPException(status.HTTP_403_FORBIDDEN, "requires role 'editor' or higher") + await SecretStore().write( + session, tenant_id=tenant_id, project_id=project_id, + name=AuthResolver.bundle_secret_name(ap.id), value={}, kind="oauth", + ) + row.status = "needs_auth" + await session.commit() diff --git a/apps/api/forge/routers/mcp_clients.py b/apps/api/forge/routers/mcp_clients.py index 8cd97b7..e7fc1e6 100644 --- a/apps/api/forge/routers/mcp_clients.py +++ b/apps/api/forge/routers/mcp_clients.py @@ -7,7 +7,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from forge.deps import CurrentUser, current_tenant_id, get_session, require_role +from forge.deps import CurrentUser, current_tenant_id, get_current_user, get_session, require_role from forge.models import McpClient router = APIRouter(prefix="/v1/projects/{project_id}/mcp-clients", tags=["mcp-clients"]) @@ -21,6 +21,9 @@ class McpClientIn(BaseModel): args: dict = {} headers_ref: str | None = None enabled: bool = True + # Attach an Auth Provider instead of (or as well as) a static header secret - this is how a + # server behind OAuth is reached, with refresh and optional per-user credentials. + auth_provider_id: str | None = None class McpClientPatch(BaseModel): @@ -29,21 +32,40 @@ class McpClientPatch(BaseModel): disabled_tools: list | None = None # remote tool names toggled off url: str | None = None headers_ref: str | None = None + auth_provider_id: str | None = None def _out(m: McpClient) -> dict: return {"id": m.id, "name": m.name, "transport": m.transport, "url": m.url, "command": m.command, "args": m.args, "headers_ref": m.headers_ref, - "enabled": m.enabled, "disabled_tools": m.disabled_tools or []} + "enabled": m.enabled, "disabled_tools": m.disabled_tools or [], + "auth_provider_id": m.auth_provider_id} @router.get("") async def list_clients(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)): - rows = (await session.execute( + """Registered MCP servers, each flagged with the connector that owns it (if any). + + A connector-owned server already surfaces its tools as a tool set, so anything picking tools + for an agent should offer the SET and skip the server - listing both makes one integration + look like two, and granting either does the same thing. + """ + from forge.models import ConnectorInstall + + rows = list((await session.execute( select(McpClient).where(McpClient.tenant_id == tenant_id, McpClient.project_id == project_id) - )).scalars() - return [_out(m) for m in rows] + )).scalars()) + owned = { + i.mcp_client_id: i.slug + for i in (await session.execute( + select(ConnectorInstall).where( + ConnectorInstall.tenant_id == tenant_id, ConnectorInstall.project_id == project_id, + ) + )).scalars() + if i.mcp_client_id + } + return [{**_out(m), "connector_slug": owned.get(m.id)} for m in rows] @router.post("", status_code=201) @@ -51,7 +73,8 @@ async def create_client(project_id: str, body: McpClientIn, session: AsyncSessio tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))): m = McpClient(tenant_id=tenant_id, project_id=project_id, name=body.name, transport=body.transport, - url=body.url, command=body.command, args=body.args, headers_ref=body.headers_ref, enabled=body.enabled) + url=body.url, command=body.command, args=body.args, headers_ref=body.headers_ref, + enabled=body.enabled, auth_provider_id=body.auth_provider_id) session.add(m) await session.commit() await session.refresh(m) @@ -77,6 +100,9 @@ async def update_client(project_id: str, client_id: str, body: McpClientPatch, s m.url = body.url if body.headers_ref is not None: m.headers_ref = body.headers_ref + if body.auth_provider_id is not None: + # "" clears the attachment (back to anonymous / headers_ref only). + m.auth_provider_id = body.auth_provider_id or None await session.commit() await session.refresh(m) # Drop the cached connection so running agents pick up the new config (audit F12). @@ -87,21 +113,25 @@ async def update_client(project_id: str, client_id: str, body: McpClientPatch, s @router.get("/{client_id}/tools") async def list_remote_tools(project_id: str, client_id: str, session: AsyncSession = Depends(get_session), - tenant_id: str = Depends(current_tenant_id)): + tenant_id: str = Depends(current_tenant_id), + user: CurrentUser = Depends(get_current_user)): """Connect to the server and list the tools it exposes - drives the 'pick which to add' UI.""" - from forge.tools.mcp import McpUnavailable, discover_tools + from forge.tools.mcp import McpUnavailable, describe_mcp_error, discover_tools row = (await session.execute( select(McpClient).where(McpClient.tenant_id == tenant_id, McpClient.id == client_id) )).scalar_one_or_none() if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "mcp client not found") + # A server behind a per-user auth provider only has THIS caller's token to answer with. try: - tools = await discover_tools(row, tenant_id, project_id) + tools = await discover_tools(row, tenant_id, project_id, {"end_user_id": str(user.id)}) except McpUnavailable as e: return {"ok": False, "error": str(e)} except Exception as e: # noqa: BLE001 - surface connect/auth errors to the UI, don't 500 - return {"ok": False, "error": f"Could not connect: {e}"} + # Unwrapped, an anyio task group reports every failure as "unhandled errors in a + # TaskGroup", which hides the 401/DNS/TLS cause the user needs to see. + return {"ok": False, "error": f"Could not connect: {describe_mcp_error(e)}"} return {"ok": True, "tools": tools} diff --git a/apps/api/forge/routers/oauth.py b/apps/api/forge/routers/oauth.py index 69c6182..8b0c482 100644 --- a/apps/api/forge/routers/oauth.py +++ b/apps/api/forge/routers/oauth.py @@ -8,11 +8,7 @@ from __future__ import annotations -import base64 -import hashlib -import secrets as _secrets from html import escape -from urllib.parse import urlencode from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import HTMLResponse @@ -20,12 +16,13 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from forge.auth_providers.oauth_flow import OAuthNotConfigured, build_authorize_url, token_request from forge.auth_providers.resolver import AuthResolver from forge.config import settings from forge.deps import CurrentUser, current_tenant_id, get_session, require_role from forge.models import AuthProvider from forge.secrets.store import SecretNotFound, SecretStore -from forge.security import TokenError, create_state_token, decode_token +from forge.security import TokenError, decode_token from forge.util.http import shared_async_client from forge.util.ssrf import guarded_request @@ -70,37 +67,16 @@ async def oauth_start( _: CurrentUser = Depends(require_role("editor")), ): ap = await _load(session, tenant_id, project_id, ap_id) - cfg = ap.config or {} - client_id = await SecretStore().read_ref(tenant_id=tenant_id, project_id=project_id, ref=cfg["client_id_ref"]) if cfg.get("client_id_ref") else None - if not client_id: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "client_id secret not configured") - # PKCE (finding i): bind the authorization code to a per-request verifier so an intercepted - # code can't be redeemed without it. The verifier rides in the SIGNED state (tamper-proof) - # and is echoed back to us in the callback. NOTE: the signed state is readable by the - # browser; for a PUBLIC client (no client_secret) store the verifier server-side instead. - verifier = _secrets.token_urlsafe(64) - challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") - state_claims = {"tid": tenant_id, "pid": project_id, "ap": ap_id, "cv": verifier} - # Per-user connect: carry ONLY the dims that key this provider's per-user bundle, so the - # callback stores the token under the same per-user name resolve/refresh look up. Absent (or - # a non-per-user provider) => the default single-account name, preserving prior behavior. - per_user = cfg.get("per_user_context_keys") or [] - ctx = (body.context if body else None) or {} - user_ctx = {k: ctx[k] for k in per_user if k in ctx} - if user_ctx: - state_claims["ctx"] = user_ctx - state = create_state_token(state_claims) - q = { - "response_type": "code", - "client_id": str(client_id), - "redirect_uri": _redirect_uri(cfg), - "state": state, - "code_challenge": challenge, - "code_challenge_method": "S256", - } - if cfg.get("scope"): - q["scope"] = cfg["scope"] - return {"authorize_url": f"{cfg['authorize_url']}?{urlencode(q)}"} + # PKCE + signed state live in auth_providers.oauth_flow, shared with the Connectors screen + # so both connect paths have identical security properties (finding i). + try: + url = await build_authorize_url( + ap, tenant_id=tenant_id, project_id=project_id, + context=(body.context if body else None) or {}, + ) + except OAuthNotConfigured as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e + return {"authorize_url": url} @router.get("/v1/oauth/callback", response_class=HTMLResponse) @@ -140,9 +116,10 @@ async def oauth_callback( # Fetch the token through the SSRF guard (validates the host pre-connect AND re-validates # any redirect hop, with httpx's cross-origin credential stripping) rather than a raw POST # that would follow a redirect to an internal host (audit S8). + form, headers = token_request(cfg, data) r = await guarded_request( shared_async_client(), "POST", cfg["token_url"], - data={k: v for k, v in data.items() if v is not None}, timeout=30, follow_redirects=True, + data=form, headers=headers, timeout=30, follow_redirects=True, ) if r.status_code >= 400: return HTMLResponse( @@ -165,7 +142,49 @@ async def oauth_callback( # provider's token was stored where resolve never looked (finding i / item 5). bundle_name = AuthResolver.bundle_secret_name(ap_id, claims.get("ctx"), cfg.get("per_user_context_keys")) await AuthResolver()._store_bundle(tenant_id, project_id, ap_id, bundle, name=bundle_name) - return HTMLResponse("

✅ Connected

You can close this window and return to Forge.

") + # The per-user dims this consent was for, carried through the signed state. Discovery below + # has to ask the server AS THAT USER - it is the only credential that exists. + note = await _finish_connector_connect(session, tenant_id, project_id, ap_id, claims.get("ctx")) + return HTMLResponse(f"

✅ Connected

You can close this window and return to Forge.{note}

") + + +async def _finish_connector_connect(session, tenant_id: str, project_id: str, ap_id: str, + context: dict | None = None) -> str: + """If this provider belongs to an installed connector, mark it connected and (for an + MCP-backed one) discover its tools now that credentials exist. + + An MCP connector cannot list its tools before consent - the server answers 401 - so install + deliberately leaves the tool set empty and this is where it gets filled. `context` is the end + user whose token was just stored; without it the discovery call would resolve a shared bundle + that a per-user connector never writes and get a second 401, leaving the connector connected + with zero actions. Failures here are reported in the page text but never fail the callback: + the token IS stored, and a user can always re-sync from the Connectors screen. + """ + from forge.connectors.install import ConnectorInstaller + from forge.models import ConnectorInstall + + install = (await session.execute( + select(ConnectorInstall).where( + ConnectorInstall.tenant_id == tenant_id, + ConnectorInstall.project_id == project_id, + ConnectorInstall.auth_provider_id == ap_id, + ) + )).scalar_one_or_none() + if install is None: + return "" + install.status = "connected" + install.status_detail = None + await session.commit() + if not install.mcp_client_id: + return "" + try: + count = await ConnectorInstaller().sync_tools(session, install, context=context) + except Exception as e: # noqa: BLE001 - the connection succeeded; discovery is recoverable + from forge.tools.mcp import describe_mcp_error + + return (" (Connected, but listing actions failed: " + f"{escape(describe_mcp_error(e)[:200])}. Use “Refresh actions”.)") + return f" {count} action(s) are now available." if count else "" @router.get(_PREFIX + "/status") diff --git a/apps/api/forge/routers/triggers.py b/apps/api/forge/routers/triggers.py index 0ea9393..83119e7 100644 --- a/apps/api/forge/routers/triggers.py +++ b/apps/api/forge/routers/triggers.py @@ -1,36 +1,197 @@ -"""List a project's triggers (webhook URLs, schedules) for the console.""" +"""A project's triggers (webhook URLs, schedules): who they run as, and who they belong to. + +Two separate questions, deliberately kept apart - conflating them is what makes automation +ownership muddy: + + * `run_as_user_id` - WHOSE connected accounts the run uses. A trigger fires with nobody signed + in, so it carries an identity; that is what lets a scheduled workflow send from a connected + Gmail. `PUT /{id}/run-as` moves it. + * `scope` - WHO the trigger belongs to, and therefore who sees it here. `project` is a team + automation (the prod monitor, the nightly build) that everyone in the project works with; + `user` is someone's own (a salesperson's lead-chaser), listed only for them. + `PUT /{id}/scope` moves it. + +They vary independently on purpose: a shared team automation can legitimately act through one +person's Slack account, and a personal automation can be handed to a colleague without becoming +everybody's. + +SCOPE IS OWNERSHIP, NOT A LOCK. A webhook URL is a credential - anyone holding it fires the +trigger whatever its scope says - and every run is visible in Traces regardless. Scope decides +whose list a trigger appears in and who may change it, nothing more. +""" from __future__ import annotations -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from forge.config import settings -from forge.deps import current_tenant_id, get_session -from forge.models import Trigger +from forge.deps import ( + CurrentUser, + current_tenant_id, + effective_role, + get_current_user, + get_session, + role_at_least, +) +from forge.models import Trigger, User +from forge.services.triggers import SCOPES router = APIRouter(prefix="/v1/projects/{project_id}/triggers", tags=["triggers"]) +class RunAsIn(BaseModel): + # Omitted / null means "me" - the common case, someone claiming an automation they now own. + user_id: str | None = None + + +class ScopeIn(BaseModel): + scope: str # project | user + + +async def _labels(session: AsyncSession, tenant_id: str, user_ids: set[str]) -> dict[str, str]: + """Map user id -> email for display. A missing id (a user who has since been removed) is + deliberately absent, so the UI can say the automation has no working identity rather than + printing a bare uuid that means nothing to anyone.""" + ids = {u for u in user_ids if u} + if not ids: + return {} + rows = (await session.execute( + select(User).where(User.tenant_id == tenant_id, User.id.in_(ids)) + )).scalars() + return {u.id: u.email for u in rows} + + @router.get("") async def list_triggers( + request: Request, project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id), + user: CurrentUser = Depends(get_current_user), ): - rows = (await session.execute( + """Project triggers, plus your own personal ones. + + Someone else's personal trigger is hidden - a colleague's private lead-chaser is noise on + your screen. It is NOT hidden from an admin, though: every run it produces is already in + Traces, so filtering it out of the one screen that explains WHY those runs happen would be + false privacy that only costs the person who has to answer for the project. + """ + rows = list((await session.execute( select(Trigger).where(Trigger.tenant_id == tenant_id, Trigger.project_id == project_id) - )).scalars() + )).scalars()) + me = str(user.id) + oversight = role_at_least(await effective_role(user, request), "admin") base = settings.public_base_url.rstrip("/") + emails = await _labels(session, tenant_id, {t.run_as_user_id for t in rows if t.run_as_user_id}) out = [] for t in rows: + personal = t.scope == "user" + mine = bool(t.run_as_user_id) and t.run_as_user_id == me + if personal and not mine and not oversight: + continue item = { "id": t.id, "workflow_id": t.workflow_id, "node_id": t.node_id, "kind": t.kind, "enabled": t.enabled, "config": t.config, "last_fired_at": t.last_fired_at.isoformat() if t.last_fired_at else None, + "scope": t.scope or "project", + "run_as_user_id": t.run_as_user_id, + # None when unset OR when that user no longer exists - both mean "this trigger has + # no connected accounts to draw on", which is the thing worth showing. + "run_as_email": emails.get(t.run_as_user_id or ""), + "run_as_is_me": mine, + # True only when you are seeing someone else's personal trigger because of your role. + "visible_via_oversight": personal and not mine, } if t.kind == "webhook_in" and t.key: item["webhook_url"] = f"{base}/v1/hooks/{t.key}" out.append(item) return out + + +@router.put("/{trigger_id}/scope") +async def set_scope( + request: Request, + project_id: str, + trigger_id: str, + body: ScopeIn, + session: AsyncSession = Depends(get_session), + tenant_id: str = Depends(current_tenant_id), + user: CurrentUser = Depends(get_current_user), +): + """Move a trigger between "the team's" and "mine". + + Sharing it with the project is an editor decision: colleagues will see it, depend on it, and + be able to reassign it. Making one personal is open to the person it runs as - it is their + account doing the work - but not to a bystander, because hiding a trigger others rely on + would look exactly like it disappearing. + """ + if body.scope not in SCOPES: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, f"scope must be one of {list(SCOPES)}") + trigger = (await session.execute( + select(Trigger).where( + Trigger.tenant_id == tenant_id, Trigger.project_id == project_id, Trigger.id == trigger_id, + ) + )).scalar_one_or_none() + if trigger is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "trigger not found") + + is_editor = role_at_least(await effective_role(user, request), "editor") + owns_it = bool(trigger.run_as_user_id) and trigger.run_as_user_id == str(user.id) + if body.scope == "project" and not is_editor: + raise HTTPException(status.HTTP_403_FORBIDDEN, "sharing a trigger with the project requires role 'editor'") + if body.scope == "user" and not (owns_it or is_editor): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "only the person a trigger runs as (or an editor) can make it personal", + ) + trigger.scope = body.scope + await session.commit() + return {"scope": trigger.scope} + + +@router.put("/{trigger_id}/run-as") +async def set_run_as( + request: Request, + project_id: str, + trigger_id: str, + body: RunAsIn | None = None, + session: AsyncSession = Depends(get_session), + tenant_id: str = Depends(current_tenant_id), + user: CurrentUser = Depends(get_current_user), +): + """Point this trigger at a person's connected accounts. + + Claiming it for YOURSELF is open to any real logged-in user - it only ever narrows the run to + accounts you personally connected. Assigning it to SOMEONE ELSE means their credentials get + used by a workflow they may not have touched, so that is an editor decision. + """ + if str(user.id).startswith(("apikey:", "service")): + raise HTTPException(status.HTTP_403_FORBIDDEN, "run-as requires a user identity") + trigger = (await session.execute( + select(Trigger).where( + Trigger.tenant_id == tenant_id, Trigger.project_id == project_id, Trigger.id == trigger_id, + ) + )).scalar_one_or_none() + if trigger is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "trigger not found") + + target = (body.user_id if body else None) or str(user.id) + if target != str(user.id): + if not role_at_least(await effective_role(user, request), "editor"): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + "assigning a trigger to another person's accounts requires role 'editor'", + ) + exists = (await session.execute( + select(User).where(User.tenant_id == tenant_id, User.id == target) + )).scalar_one_or_none() + if exists is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "no such user in this workspace") + + trigger.run_as_user_id = target + await session.commit() + emails = await _labels(session, tenant_id, {target}) + return {"run_as_user_id": target, "run_as_email": emails.get(target)} diff --git a/apps/api/forge/routers/workflows.py b/apps/api/forge/routers/workflows.py index b2f6241..448ded6 100644 --- a/apps/api/forge/routers/workflows.py +++ b/apps/api/forge/routers/workflows.py @@ -18,6 +18,7 @@ WorkflowUpdate, ) from forge.services.portability import PortabilityService +from forge.services.triggers import default_scope_for from forge.services.versions import safe_snapshot from forge.services.workflows import WorkflowService @@ -120,7 +121,10 @@ async def update_executable( wf = await WorkflowService.get(session, tenant_id, workflow_id) if wf is None: raise HTTPException(404, "Workflow not found") - result = await WorkflowService.update_executable(session, wf, body.executable, require_valid=True) + result = await WorkflowService.update_executable( + session, wf, body.executable, require_valid=True, + owner=str(user.id), scope=default_scope_for(user.role), + ) if result.valid: await safe_snapshot(session, "workflow", wf, author=user) return ValidateOut(valid=result.valid, errors=result.errors, warnings=result.warnings) @@ -162,7 +166,12 @@ async def publish_workflow( wf.active_version = (wf.active_version or 1) + 1 await session.commit() await session.refresh(wf) - await WorkflowService._sync_triggers(session, wf) # (re)register webhook/schedule/etc. + # (Re)register webhook/schedule/etc. The publisher becomes the identity an unattended run + # acts as, and (for a NEW trigger) whether it is the team's or their own - unless the + # trigger already has those, which an edit never overwrites. + await WorkflowService._sync_triggers( + session, wf, owner=str(user.id), scope=default_scope_for(user.role), + ) await safe_snapshot(session, "workflow", wf, author=user) return wf @@ -193,6 +202,9 @@ async def save_canvas( wf = await WorkflowService.get(session, tenant_id, workflow_id) if wf is None: raise HTTPException(404, "Workflow not found") - result = await WorkflowService.save_canvas(session, wf, body.canvas, body.executable) + result = await WorkflowService.save_canvas( + session, wf, body.canvas, body.executable, + owner=str(user.id), scope=default_scope_for(user.role), + ) await safe_snapshot(session, "workflow", wf, author=user) return ValidateOut(valid=result.valid, errors=result.errors, warnings=result.warnings) diff --git a/apps/api/forge/schemas/dto.py b/apps/api/forge/schemas/dto.py index 4e208a2..c169aa9 100644 --- a/apps/api/forge/schemas/dto.py +++ b/apps/api/forge/schemas/dto.py @@ -31,6 +31,7 @@ class ProjectCountsOut(BaseModel): knowledge: int auth: int handoffs: int + connectors: int = 0 class ProjectCreate(BaseModel): diff --git a/apps/api/forge/services/dispatch.py b/apps/api/forge/services/dispatch.py index e1492d9..acd5cd1 100644 --- a/apps/api/forge/services/dispatch.py +++ b/apps/api/forge/services/dispatch.py @@ -49,10 +49,16 @@ async def dispatch_trigger(run_service: RunService, trigger: Trigger, payload, * schedule (re-check + stamp in one txn) before calling this, so it passes stamp=False to avoid a redundant second write; the webhook/inbound paths keep the default True.""" run_input = TriggerService.build_input(trigger, payload) + # Whose accounts this unattended run acts as. A webhook or a schedule has nobody signed in, + # so without an identity here every per-user connector (which is every catalog connector) + # would fail at its first tool call with "the acting user has not connected their credential". + # The trigger carries the editor who set it up, so a scheduled workflow reads THEIR mailbox - + # the same account they connected when building it. + end_user = {"id": trigger.run_as_user_id} if trigger.run_as_user_id else None async with SessionLocal() as s: run = await run_service.create_run( s, tenant_id=trigger.tenant_id, project_id=trigger.project_id, - workflow_id=trigger.workflow_id, input=run_input, + workflow_id=trigger.workflow_id, input=run_input, end_user=end_user, source=_TRIGGER_SOURCE.get(trigger.kind, trigger.kind or "webhook"), ) run_id = run.id diff --git a/apps/api/forge/services/projects.py b/apps/api/forge/services/projects.py index 0315445..b6ed25d 100644 --- a/apps/api/forge/services/projects.py +++ b/apps/api/forge/services/projects.py @@ -15,6 +15,7 @@ AuthProvider, Channel, Component, + ConnectorInstall, Dataset, HandoffRequest, KbSource, @@ -44,6 +45,7 @@ "components": Component, "knowledge": KbSource, "auth": AuthProvider, + "connectors": ConnectorInstall, } @@ -182,6 +184,7 @@ async def delete(session: AsyncSession, project: Project, *, checkpointer: Any = KbSource, QaPair, McpClient, + ConnectorInstall, Channel, Trigger, Dataset, diff --git a/apps/api/forge/services/runtime.py b/apps/api/forge/services/runtime.py index 6f34f99..57bcb7a 100644 --- a/apps/api/forge/services/runtime.py +++ b/apps/api/forge/services/runtime.py @@ -164,8 +164,11 @@ async def build_compile_context( # Pre-load enabled MCP servers' tools (one connect per server) so agent nodes can # attach them - the agent factory is sync, but MCP discovery is async. from forge.models import McpClient - from forge.tools.mcp import server_tools + from forge.tools.mcp import auth_context_from, server_tools + # The run's per-user dims, so a server behind a PER-USER auth provider resolves the calling + # end user's own credential here rather than a shared workspace token. + mcp_auth_context = auth_context_from(ctx) mcp_by_client: dict[str, list] = {} mcp_rows = ( await session.execute( @@ -178,7 +181,7 @@ async def build_compile_context( ).scalars() for m in mcp_rows: try: - mcp_by_client[m.id] = await server_tools(m, tenant_id, project_id) + mcp_by_client[m.id] = await server_tools(m, tenant_id, project_id, mcp_auth_context) except Exception as e: # noqa: BLE001 - an unreachable server must not break the run log.warning("MCP server %s unavailable, skipping its tools: %s", m.name, e) ctx.mcp_tools_by_client = mcp_by_client diff --git a/apps/api/forge/services/triggers.py b/apps/api/forge/services/triggers.py index 9e89fcc..e9e56ab 100644 --- a/apps/api/forge/services/triggers.py +++ b/apps/api/forge/services/triggers.py @@ -16,11 +16,39 @@ from forge.models import Trigger from forge.nodes.triggers import TRIGGER_TYPES +#: Scopes a trigger can have. "project" is a team automation everyone sees; "user" is someone's +#: own, listed only for them. +SCOPES = ("project", "user") + + +def default_scope_for(role: str | None) -> str: + """Whether a NEW trigger this person creates belongs to the project or to them. + + Admins and owners are configuring the project - a build pipeline, a prod monitor - so what + they add is the team's. Everyone else is building for themselves (a salesperson's own + lead-chaser), so theirs stays theirs until they deliberately share it. Either way the choice + is only a default: both directions are one click on the Triggers screen. + """ + return "project" if role in ("owner", "admin") else "user" + class TriggerService: @staticmethod - async def sync_from_workflow(session, workflow) -> list[Trigger]: - """Upsert one Trigger per trigger node in the workflow; drop removed ones.""" + async def sync_from_workflow(session, workflow, *, owner: str | None = None, + scope: str | None = None) -> list[Trigger]: + """Upsert one Trigger per trigger node in the workflow; drop removed ones. + + `owner` is the editor doing the save. It becomes the trigger's `run_as_user_id` - the + identity an unattended run acts as - and is only ever stamped on a trigger that doesn't + have one yet. A colleague editing the workflow must NOT silently take ownership: the + automation runs on whoever's Gmail/Slack account is connected behind it, and quietly + repointing that at the last person to touch the canvas would change what the workflow + can see without anyone deciding to. Reassignment is explicit (Triggers screen). + + `scope` is the same story for ownership: applied only when the trigger is NEW. Editing a + workflow must never move a team automation into someone's private list, nor publish + someone's private one to the whole project. + """ ex = workflow.executable or {} nodes = [n for n in ex.get("nodes", []) if isinstance(n, dict) and n.get("type") in TRIGGER_TYPES] existing = list((await session.execute( @@ -39,7 +67,8 @@ async def sync_from_workflow(session, workflow) -> list[Trigger]: tenant_id=workflow.tenant_id, project_id=workflow.project_id, workflow_id=workflow.id, node_id=node_id, kind=n["type"], key=uuid.uuid4().hex if n["type"] == "webhook_in" else None, - config=cfg, enabled=True, + config=cfg, enabled=True, run_as_user_id=owner, + scope=(scope if scope in SCOPES else "project"), ) session.add(trig) else: @@ -47,6 +76,10 @@ async def sync_from_workflow(session, workflow) -> list[Trigger]: trig.config = cfg if n["type"] == "webhook_in" and not trig.key: trig.key = uuid.uuid4().hex + # Adopt an owner for a trigger created before this column existed, but never + # replace one that is already set (see the docstring). + if not trig.run_as_user_id and owner: + trig.run_as_user_id = owner out.append(trig) # Remove triggers whose node no longer exists. for t in existing: diff --git a/apps/api/forge/services/workflows.py b/apps/api/forge/services/workflows.py index 6d965dc..a5756bf 100644 --- a/apps/api/forge/services/workflows.py +++ b/apps/api/forge/services/workflows.py @@ -94,19 +94,21 @@ def validate(executable: dict) -> ValidationResult: @staticmethod async def update_executable( - session: AsyncSession, wf: Workflow, executable: dict, *, require_valid: bool = True + session: AsyncSession, wf: Workflow, executable: dict, *, require_valid: bool = True, + owner: str | None = None, scope: str | None = None, ) -> ValidationResult: result = validate_workflow(executable) if result.valid or not require_valid: wf.executable = executable await session.commit() await session.refresh(wf) - await WorkflowService._sync_triggers(session, wf) + await WorkflowService._sync_triggers(session, wf, owner=owner, scope=scope) return result @staticmethod async def save_canvas( - session: AsyncSession, wf: Workflow, canvas: dict, executable: dict + session: AsyncSession, wf: Workflow, canvas: dict, executable: dict, + *, owner: str | None = None, scope: str | None = None, ) -> ValidationResult: """Persist the canvas (UI-owned) + compiled executable, and validate. @@ -118,15 +120,22 @@ async def save_canvas( wf.executable = executable await session.commit() await session.refresh(wf) - await WorkflowService._sync_triggers(session, wf) + await WorkflowService._sync_triggers(session, wf, owner=owner, scope=scope) return result @staticmethod - async def _sync_triggers(session: AsyncSession, wf: Workflow) -> None: - """Mirror the workflow's trigger nodes into Trigger rows (best-effort).""" + async def _sync_triggers(session: AsyncSession, wf: Workflow, *, owner: str | None = None, + scope: str | None = None) -> None: + """Mirror the workflow's trigger nodes into Trigger rows (best-effort). + + `owner` (the editor saving) is stamped on triggers that don't have one yet - it is the + identity an unattended run acts as, so a scheduled workflow can use the connected + accounts of the person who built it. `scope` decides whether a NEW trigger belongs to + the project or to that person. Neither ever overwrites an existing trigger's values. + """ try: from forge.services.triggers import TriggerService - await TriggerService.sync_from_workflow(session, wf) + await TriggerService.sync_from_workflow(session, wf, owner=owner, scope=scope) except Exception: # noqa: BLE001 - trigger sync must not block saving a workflow pass diff --git a/apps/api/forge/tools/mcp.py b/apps/api/forge/tools/mcp.py index d0cd235..ee74d49 100644 --- a/apps/api/forge/tools/mcp.py +++ b/apps/api/forge/tools/mcp.py @@ -6,6 +6,16 @@ `remote_tool_name` to expose and any `inject_context` keys to fill from the per-user runtime context (so the model never sets secrets like user_id/api_key). +A server may be authenticated two ways, and they compose: + +* `headers_ref` - a static header secret (an API key / PAT). Simple, shared, no refresh. +* `auth_provider_id` - a full Auth Provider. This is what reaches an OAuth-protected remote + MCP server: tokens refresh automatically, and a PER-USER provider resolves a different + credential for each end user, so an agent acts as the calling user rather than through one + shared workspace token. Provider headers are applied per REQUEST (via httpx.Auth) rather + than baked into the connection, so a token that rotates mid-session is picked up without + reconnecting. + MCP discovery is async, so MCP tools are loaded by `load_mcp_tools` from the runtime assembler (not the sync `materialize_tool` path). """ @@ -13,30 +23,37 @@ from __future__ import annotations import contextlib +import hashlib import logging import time from typing import Any +import httpx from langchain.tools import ToolRuntime from sqlalchemy import select from forge.db.base import SessionLocal -from forge.models import McpClient +from forge.models import AuthProvider, McpClient from forge.secrets.store import SecretStore log = logging.getLogger("forge.mcp") -# Cache MultiServerMCPClient instances per mcp_client_id with a TTL so a dead connection or +# Cache MultiServerMCPClient instances per cache key with a TTL so a dead connection or # an edited server config is eventually re-established without a process restart (audit F12). -# `invalidate_client` drops one entry immediately (called when the McpClient row changes); +# The key is `mcp_client_id` for a shared server, and `mcp_client_id::` when +# the attached auth provider is per-user - WITHOUT that suffix one end user's authenticated +# session would be handed to the next caller of the same server. +# `invalidate_client` drops every entry for a server (called when the McpClient row changes); # `close_all` is called on shutdown. _CLIENT_CACHE: dict[str, tuple[float, Any]] = {} _CACHE_TTL = 300.0 # seconds def invalidate_client(client_id: str) -> None: - """Drop a cached MCP client so the next run reconnects with the latest config.""" - _CLIENT_CACHE.pop(client_id, None) + """Drop every cached connection for a server so the next run reconnects with the latest + config - including all per-user variants, which share the `::` prefix.""" + for key in [k for k in _CLIENT_CACHE if k == client_id or k.startswith(client_id + "::")]: + _CLIENT_CACHE.pop(key, None) async def close_all() -> None: @@ -75,7 +92,85 @@ async def _validate_mcp_url(url: str | None) -> None: await validate_url(url, EgressPolicy.from_settings()) -async def _connection_for(client_row: McpClient, tenant_id: str, project_id: str) -> dict: +async def _load_provider(tenant_id: str, provider_id: str) -> AuthProvider | None: + async with SessionLocal() as session: + return (await session.execute( + select(AuthProvider).where(AuthProvider.tenant_id == tenant_id, AuthProvider.id == provider_id) + )).scalar_one_or_none() + + +class _ProviderAuth(httpx.Auth): + """Attach an Auth Provider's resolved headers to every request to an MCP server. + + Resolution happens per REQUEST (the resolver has its own TTL cache, so this is a dict + lookup in the common case). That is what lets a rotated OAuth token take effect without + tearing down the MCP session, and what makes a per-user provider resolve the CALLER's + credential rather than whichever one happened to be live when the session opened. + + On a 401/403 the cached auth is invalidated and the request is retried exactly once - the + same invalidate-on-401 contract the REST tool follows. One retry, not a loop: if a freshly + minted token is also rejected, the credential is wrong and retrying would only amplify a + failing call into several. + """ + + requires_response_body = False + + def __init__(self, resolver, *, tenant_id: str, project_id: str, provider_id: str, context: dict) -> None: + self._resolver = resolver + self._tenant_id = tenant_id + self._project_id = project_id + self._provider_id = provider_id + self._context = context or {} + + async def _apply(self, request: httpx.Request, *, force: bool) -> None: + resolved = await self._resolver.resolve( + tenant_id=self._tenant_id, project_id=self._project_id, + provider_id=self._provider_id, context=self._context, force=force, + ) + for k, v in resolved.headers.items(): + request.headers[k] = v + if resolved.cookies: + jar = "; ".join(f"{k}={v}" for k, v in resolved.cookies.items()) + existing = request.headers.get("Cookie") + request.headers["Cookie"] = f"{existing}; {jar}" if existing else jar + if resolved.params: + request.url = request.url.copy_merge_params(resolved.params) + + async def async_auth_flow(self, request: httpx.Request): + await self._apply(request, force=False) + response = yield request + if response.status_code in (401, 403): + await self._apply(request, force=True) + yield request + + +async def _auth_for(client_row: McpClient, tenant_id: str, project_id: str, context: dict | None): + """(httpx.Auth | None, cache-key suffix) for a server's attached auth provider.""" + if not getattr(client_row, "auth_provider_id", None): + return None, "" + provider = await _load_provider(tenant_id, client_row.auth_provider_id) + if provider is None: + log.warning("MCP server %s references missing auth provider %s", client_row.name, client_row.auth_provider_id) + return None, "" + from forge.auth_providers.resolver import AuthResolver + + context = context or {} + per_user = (provider.config or {}).get("per_user_context_keys") or [] + # Only a per-user provider needs a per-caller connection; a shared one keeps a single + # pooled session for the whole project (which is the overwhelmingly common case). + suffix = "" + if per_user: + dims = "|".join(f"{k}={context.get(k)}" for k in sorted(per_user)) + suffix = "::" + hashlib.sha256(dims.encode()).hexdigest()[:16] + auth = _ProviderAuth( + AuthResolver(), tenant_id=tenant_id, project_id=project_id, + provider_id=provider.id, context=context, + ) + return auth, suffix + + +async def _connection_for(client_row: McpClient, tenant_id: str, project_id: str, + context: dict | None = None) -> dict: from forge.config import settings transport = client_row.transport or "streamable_http" @@ -108,46 +203,99 @@ async def _connection_for(client_row: McpClient, tenant_id: str, project_id: str conn["headers"] = headers except Exception: # noqa: BLE001 - missing headers secret => connect without pass + # stdio has no HTTP layer to attach auth to; a provider on a stdio server is a config + # mistake rather than something to silently half-apply. + if transport != "stdio": + auth, _suffix = await _auth_for(client_row, tenant_id, project_id, context) + if auth is not None: + conn["auth"] = auth return conn -async def _client_and_tools(client_row: McpClient, tenant_id: str, project_id: str): +async def _client_and_tools(client_row: McpClient, tenant_id: str, project_id: str, + context: dict | None = None): MultiServerMCPClient = _require_adapters() now = time.monotonic() - entry = _CLIENT_CACHE.get(client_row.id) + _auth, suffix = await _auth_for(client_row, tenant_id, project_id, context) + key = client_row.id + suffix + entry = _CLIENT_CACHE.get(key) if entry is None or (now - entry[0]) > _CACHE_TTL: - conn = await _connection_for(client_row, tenant_id, project_id) + conn = await _connection_for(client_row, tenant_id, project_id, context) client = MultiServerMCPClient({client_row.name: conn}) - _CLIENT_CACHE[client_row.id] = (now, client) + _CLIENT_CACHE[key] = (now, client) else: client = entry[1] tools = await client.get_tools() return client, tools -async def discover_tools(client_row: McpClient, tenant_id: str, project_id: str) -> list[dict]: +async def discover_tools(client_row: McpClient, tenant_id: str, project_id: str, + context: dict | None = None) -> list[dict]: """List the tools an MCP server exposes - [{name, description}]. Connects fresh (not via the execution cache) so the result always reflects the current McpClient config, and drops any stale cached client so the next run reconnects with the latest settings. Raises McpUnavailable / connection errors. """ - _CLIENT_CACHE.pop(client_row.id, None) + invalidate_client(client_row.id) MultiServerMCPClient = _require_adapters() - conn = await _connection_for(client_row, tenant_id, project_id) + conn = await _connection_for(client_row, tenant_id, project_id, context) client = MultiServerMCPClient({client_row.name: conn}) tools = await client.get_tools() return [{"name": t.name, "description": (getattr(t, "description", "") or "").strip()} for t in tools] -async def server_tools(client_row: McpClient, tenant_id: str, project_id: str) -> list: +def describe_mcp_error(e: BaseException) -> str: + """A message that names what actually failed. + + The MCP client runs its transport inside an anyio task group, so EVERY failure - a 401 from + the server, a DNS miss, a TLS error - reaches the caller wrapped in an ExceptionGroup whose + str() is the famously uninformative "unhandled errors in a TaskGroup (1 sub-exception)". + Printing that in the UI tells a user nothing and tells us nothing either, so unwrap to the + leaf exceptions and name them. + """ + leaves: list[str] = [] + + def _walk(err: BaseException, depth: int = 0) -> None: + inner = getattr(err, "exceptions", None) + if inner and depth < 5: + for sub in inner: + _walk(sub, depth + 1) + return + text = str(err).strip() + leaves.append(f"{type(err).__name__}: {text}" if text else type(err).__name__) + + _walk(e) + # Deduplicate: a task group commonly reports the same underlying error from several tasks. + unique = list(dict.fromkeys(leaves)) + return "; ".join(unique[:3]) or str(e) or type(e).__name__ + + +async def server_tools(client_row: McpClient, tenant_id: str, project_id: str, + context: dict | None = None) -> list: """Native LangChain tools a server exposes, minus the ones toggled off (disabled_tools). - Used to attach a whole MCP server's tools to an agent.""" - _client, tools = await _client_and_tools(client_row, tenant_id, project_id) + Used to attach a whole MCP server's tools to an agent. `context` carries the run's per-user + dims so a per-user auth provider resolves THIS caller's credential.""" + _client, tools = await _client_and_tools(client_row, tenant_id, project_id, context) disabled = set(getattr(client_row, "disabled_tools", None) or []) return [t for t in tools if t.name not in disabled] +def auth_context_from(ctx) -> dict: + """The per-user dims an MCP auth provider keys on, read off a CompileContext. + + Mirrors the lane order the REST tool uses (tools/rest.py): injected run context first, then + the run's end_user identity last so it is authoritative and can't be shadowed by a value a + caller injected.""" + eu = getattr(ctx, "end_user", None) + return { + **(getattr(ctx, "run_context", None) or {}), + "end_user": eu, + "end_user_id": eu.get("id") if isinstance(eu, dict) else None, + "end_user_email": eu.get("email") if isinstance(eu, dict) else None, + } + + def _wrap_with_context_injection(tool, inject_keys: list[str]): """Wrap an MCP StructuredTool so `inject_keys` are filled from runtime.context (per-user secrets the widget/channel supplies) instead of from the model.""" @@ -180,7 +328,7 @@ async def load_mcp_tool(cfg: dict, ctx) -> Any: ).scalar_one_or_none() if row is None: raise McpUnavailable(f"MCP client {cfg.get('mcp_client_id')!r} not found") - _client, tools = await _client_and_tools(row, ctx.tenant_id, ctx.project_id) + _client, tools = await _client_and_tools(row, ctx.tenant_id, ctx.project_id, auth_context_from(ctx)) name = cfg["remote_tool_name"] match = next((t for t in tools if t.name == name), None) if match is None: diff --git a/apps/api/forge/tools/rest.py b/apps/api/forge/tools/rest.py index 50f25c8..f723c87 100644 --- a/apps/api/forge/tools/rest.py +++ b/apps/api/forge/tools/rest.py @@ -26,7 +26,12 @@ from langchain.tools import ToolRuntime from pydantic import Field, create_model -from forge.auth_providers.templates import has_each_directive, render_template, render_value +from forge.auth_providers.templates import ( + DIRECTIVES, + has_structural_directive, + render_template, + render_value, +) from forge.config import settings from forge.tools.projection import cap_payload, project_response from forge.tracing import tool_io @@ -234,18 +239,19 @@ def _build_body(req: dict, fields: list[dict], values: dict, context: dict | Non tmpl = req.get("body_template") if tmpl: tvars = {"input": values, "ctx": context or {}, "env": settings.tool_vars} - # A `$each` loop directive needs STRUCTURAL rendering (parse the JSON, then walk it with - # render_value) so the produced array is always valid JSON with native types - plain string - # substitution can't build a variable-length array without trailing-comma/quoting bugs. - # Gate on an ACTUAL parsed `$each` directive (a dict key), NOT a substring of the raw text: - # a template that merely mentions "$each" inside a string value must keep the exact + # A `$each` loop (or a `$mime` message) needs STRUCTURAL rendering (parse the JSON, then + # walk it with render_value) so the produced value is always valid JSON with native types + # - plain string substitution can't build a variable-length array without trailing-comma/ + # quoting bugs, nor base64-encode a MIME message. + # Gate on an ACTUAL parsed directive (a dict key), NOT a substring of the raw text: a + # template that merely mentions "$each" inside a string value must keep the exact # string-substitution behavior (structural rendering coerces token types differently). - if "$each" in tmpl: + if any(d in tmpl for d in DIRECTIVES): try: parsed = _json.loads(tmpl) except ValueError: parsed = None - if parsed is not None and has_each_directive(parsed): + if parsed is not None and has_structural_directive(parsed): return render_value(parsed, tvars, allow_each=True, strict_ns=_STRICT_NS) rendered = render_template(tmpl, tvars, strict_ns=_STRICT_NS) if isinstance(rendered, (dict, list)): diff --git a/apps/api/migrations/versions/0011_connectors.py b/apps/api/migrations/versions/0011_connectors.py new file mode 100644 index 0000000..33eaa31 --- /dev/null +++ b/apps/api/migrations/versions/0011_connectors.py @@ -0,0 +1,67 @@ +"""Connectors: connector_installs table + mcp_clients.auth_provider_id + +A connector install is the receipt for a manifest that was expanded into ordinary rows +(AuthProvider + ToolSet + Tools + optionally an McpClient). The table records what was +created so uninstall/upgrade are exact rather than guesswork. + +`mcp_clients.auth_provider_id` lets an external MCP server be authenticated with a full Auth +Provider (OAuth with refresh, per-user bundles) instead of only a static `headers_ref` secret - +which is what makes OAuth-protected remote MCP servers reachable. + +`create_all` builds both on fresh dev DBs; this migration covers managed Postgres and +pre-existing tables. Idempotent: each piece is added only when absent. + +Revision ID: 0011_connectors +Revises: 0010_toolset_exposed +Create Date: 2026-08-13 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0011_connectors" +down_revision = "0010_toolset_exposed" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + insp = sa.inspect(op.get_bind()) + tables = set(insp.get_table_names()) + + if "connector_installs" not in tables: + op.create_table( + "connector_installs", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("tenant_id", sa.String(36), nullable=False, index=True), + sa.Column("project_id", sa.String(36), nullable=False, index=True), + sa.Column("slug", sa.String(120), nullable=False, index=True), + sa.Column("name", sa.String(120), nullable=False), + sa.Column("version", sa.String(40), nullable=False, server_default="1.0.0"), + sa.Column("source", sa.String(20), nullable=False, server_default="catalog"), + sa.Column("manifest", sa.JSON(), nullable=True), + sa.Column("auth_provider_id", sa.String(36), nullable=True), + sa.Column("tool_set_id", sa.String(36), nullable=True), + sa.Column("mcp_client_id", sa.String(36), nullable=True), + sa.Column("created_tool_ids", sa.JSON(), nullable=True), + sa.Column("created_secret_names", sa.JSON(), nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="needs_setup"), + sa.Column("status_detail", sa.Text(), nullable=True), + sa.Column("auth_mode", sa.String(20), nullable=False, server_default="shared"), + sa.UniqueConstraint("tenant_id", "project_id", "slug", name="uq_connector_install_slug"), + ) + + if "mcp_clients" in tables: + cols = {c["name"] for c in insp.get_columns("mcp_clients")} + if "auth_provider_id" not in cols: + op.add_column("mcp_clients", sa.Column("auth_provider_id", sa.String(36), nullable=True)) + + +def downgrade() -> None: + insp = sa.inspect(op.get_bind()) + tables = set(insp.get_table_names()) + if "mcp_clients" in tables and "auth_provider_id" in {c["name"] for c in insp.get_columns("mcp_clients")}: + op.drop_column("mcp_clients", "auth_provider_id") + if "connector_installs" in tables: + op.drop_table("connector_installs") diff --git a/apps/api/migrations/versions/0012_trigger_run_as.py b/apps/api/migrations/versions/0012_trigger_run_as.py new file mode 100644 index 0000000..cd91816 --- /dev/null +++ b/apps/api/migrations/versions/0012_trigger_run_as.py @@ -0,0 +1,40 @@ +"""Triggers: run_as_user_id - whose connected accounts an unattended run uses + +A webhook or a schedule has no signed-in person. Every catalog connector is per-user, so +without an identity on the trigger a scheduled run has no token to resolve and fails at its +first tool call. This column names the user an unattended run acts as - the editor who saved +the workflow, reassignable afterwards. + +NULL is the pre-existing behaviour (no identity), so this is safe to apply to a live DB: nothing +changes until a workflow is next saved, which stamps the owner. + +`create_all` builds it on fresh dev DBs; this migration covers managed Postgres. Idempotent. + +Revision ID: 0012_trigger_run_as +Revises: 0011_connectors +Create Date: 2026-08-14 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0012_trigger_run_as" +down_revision = "0011_connectors" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + insp = sa.inspect(op.get_bind()) + if "triggers" not in set(insp.get_table_names()): + return + cols = {c["name"] for c in insp.get_columns("triggers")} + if "run_as_user_id" not in cols: + op.add_column("triggers", sa.Column("run_as_user_id", sa.String(36), nullable=True)) + + +def downgrade() -> None: + insp = sa.inspect(op.get_bind()) + if "triggers" not in set(insp.get_table_names()): + return + if "run_as_user_id" in {c["name"] for c in insp.get_columns("triggers")}: + op.drop_column("triggers", "run_as_user_id") diff --git a/apps/api/migrations/versions/0013_trigger_scope.py b/apps/api/migrations/versions/0013_trigger_scope.py new file mode 100644 index 0000000..6a746b2 --- /dev/null +++ b/apps/api/migrations/versions/0013_trigger_scope.py @@ -0,0 +1,44 @@ +"""Triggers: scope - a team automation, or someone's own + +Forge projects are shared, but not every automation in one is. A salesperson's lead-chaser is +theirs; a platform team's prod-monitor belongs to the project. `scope` says which, and drives +who the Triggers screen lists it for. + +Existing rows become "project", which is exactly how they behave today - everyone in the project +already sees every trigger - so this migration changes no observable behaviour on its own. + +`create_all` builds it on fresh dev DBs; this migration covers managed Postgres. Idempotent. + +Revision ID: 0013_trigger_scope +Revises: 0012_trigger_run_as +Create Date: 2026-08-14 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0013_trigger_scope" +down_revision = "0012_trigger_run_as" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + insp = sa.inspect(op.get_bind()) + if "triggers" not in set(insp.get_table_names()): + return + if "scope" in {c["name"] for c in insp.get_columns("triggers")}: + return + # server_default backfills every existing row in one statement; the model's own default + # takes over for new rows. + op.add_column( + "triggers", + sa.Column("scope", sa.String(10), nullable=False, server_default="project"), + ) + + +def downgrade() -> None: + insp = sa.inspect(op.get_bind()) + if "triggers" not in set(insp.get_table_names()): + return + if "scope" in {c["name"] for c in insp.get_columns("triggers")}: + op.drop_column("triggers", "scope") diff --git a/apps/api/tests/test_body_template_loop.py b/apps/api/tests/test_body_template_loop.py index aa474f3..73f1b28 100644 --- a/apps/api/tests/test_body_template_loop.py +++ b/apps/api/tests/test_body_template_loop.py @@ -1,19 +1,22 @@ -"""`$each` loop support in JSON body templates (batch many rows in one call). +"""Structural directives in JSON body templates - `$each` (batch rows) and `$mime` (email). -Before this, one REST tool call could only build a fixed-shape body, so an agent editing N rows -made N calls (slow, context-heavy, and prone to blowing the graph recursion limit). A body -template can now carry a `{"$each": "{{input.rows}}", "$as": "row", "$do": {...}}` directive that -expands one list-valued arg into a variable-length JSON array - so N edits go out in ONE request. +Both exist for the same reason: a value the model cannot be asked to produce as text. + +* `{"$each": "{{input.rows}}", "$as": "row", "$do": {...}}` expands one list-valued arg into a + variable-length JSON array, so an agent editing N rows makes ONE call instead of N (slow, + context-heavy, and prone to blowing the graph recursion limit). +* `{"$mime": {"to": …, "subject": …, "text": …}}` builds an RFC 2822 message and base64url-encodes + it, because Gmail's send endpoint accepts nothing else and a model cannot base64-encode by hand. Rendering is structural (parse JSON, then walk with render_value): the output is always valid JSON with native types preserved, unlike string-concatenating an array. The path is opt-in on the -"$each" marker, so existing string-substitution templates are untouched. +directive marker, so existing string-substitution templates are untouched. """ from __future__ import annotations import json -from forge.auth_providers.templates import has_each_directive, render_template, render_value +from forge.auth_providers.templates import has_structural_directive, render_template, render_value from forge.tools.rest import _build_body @@ -62,10 +65,10 @@ def test_render_value_each_is_opt_in_only(): assert out == {"$each": [{"x": "a"}], "$as": "row", "$do": {"x": None}} -def test_has_each_directive_ignores_literal_string_value(): +def test_structural_directive_detection_ignores_literal_string_values(): # A directive is a "$each" KEY; the literal text "$each" inside a string value is not one. - assert has_each_directive({"note": "use $each to loop", "qty": "{{input.qty}}"}) is False - assert has_each_directive({"rows": {"$each": "{{x}}", "$do": {}}}) is True + assert has_structural_directive({"note": "use $each to loop", "qty": "{{input.qty}}"}) is False + assert has_structural_directive({"rows": {"$each": "{{x}}", "$do": {}}}) is True def test_build_body_literal_dollar_each_in_string_keeps_string_substitution(): @@ -157,3 +160,73 @@ def test_build_body_without_each_is_unchanged(): body_template = '{ "orderId": "{{input.orderId}}", "lineNums": [ {{input.lineNum}} ] }' body = _build_body({"body_template": body_template}, [], {"orderId": "Q", "lineNum": 7}, {}) assert body == {"orderId": "Q", "lineNums": [7]} + + +# --- $mime: build an RFC 2822 message server-side -------------------------------------------- +# +# Gmail's send endpoint accepts ONLY a base64url-encoded MIME message. Declaring that as a tool +# argument means asking a model to base64-encode by hand; it can't, and the malformed result +# comes back as an opaque HTTP 400. So the model supplies to/subject/body and this does the rest. + +def _decoded(body: dict): + import base64 + import email + + raw = body["raw"] + blob = base64.urlsafe_b64decode(raw) + return blob, email.message_from_bytes(blob) + + +def test_mime_directive_builds_a_decodable_message(): + tmpl = ('{"raw": {"$mime": {"to": "{{input.to}}", "subject": "{{input.subject}}", ' + '"text": "{{input.body}}"}}}') + body = _build_body({"body_template": tmpl}, [], + {"to": "a@example.com", "subject": "Hello", "body": "Line one\nLine two"}, {}) + assert list(body) == ["raw"] + blob, msg = _decoded(body) + assert msg["To"] == "a@example.com" + assert msg["Subject"] == "Hello" + # RFC 2822 wants CRLF everywhere, headers and body alike, and the SMTP policy normalises the + # body's newlines to match. Mail clients render that as ordinary line breaks. + assert msg.get_payload(decode=True).decode().replace("\r\n", "\n").strip() == "Line one\nLine two" + assert b"\r\n" in blob + + +def test_mime_omits_empty_headers_rather_than_sending_them_blank(): + """A model that leaves cc out sends "" - and `Cc: ` on the wire is what makes Gmail answer + 400 rather than simply having no Cc.""" + tmpl = ('{"raw": {"$mime": {"to": "{{input.to}}", "cc": "{{input.cc}}", ' + '"subject": "{{input.subject}}", "text": "{{input.body}}"}}}') + body = _build_body({"body_template": tmpl}, [], + {"to": "a@example.com", "cc": "", "subject": "S", "body": "B"}, {}) + _blob, msg = _decoded(body) + assert msg["Cc"] is None + assert msg["To"] == "a@example.com" + + +def test_mime_handles_address_lists_and_non_ascii_subjects(): + tmpl = ('{"raw": {"$mime": {"to": "{{input.to}}", "subject": "{{input.subject}}", ' + '"text": "{{input.body}}"}}}') + body = _build_body({"body_template": tmpl}, [], + {"to": ["a@example.com", "b@example.com"], "subject": "Update ✅", "body": "x"}, {}) + _blob, msg = _decoded(body) + assert msg["To"] == "a@example.com, b@example.com" + # Non-ASCII must be RFC 2047 encoded, and must decode back to what was asked for. + from email.header import decode_header, make_header + assert str(make_header(decode_header(msg["Subject"]))) == "Update ✅" + + +def test_mime_is_inert_without_the_structural_opt_in(): + """Every other caller (auth token_fetch rules, data-node payloads) must keep treating a + literal "$mime" key as an ordinary key rather than executing it.""" + from forge.auth_providers.templates import render_value + + tmpl = {"raw": {"$mime": {"to": "{{input.to}}"}}} + out = render_value(tmpl, {"input": {"to": "a@example.com"}}) + assert out == {"raw": {"$mime": {"to": "a@example.com"}}} + + +def test_a_literal_dollar_mime_string_does_not_trigger_structural_rendering(): + body_template = '{ "note": "use $mime for email", "qty": {{input.qty}} }' + body = _build_body({"body_template": body_template}, [], {"qty": 3}, {}) + assert body == {"note": "use $mime for email", "qty": 3} diff --git a/apps/api/tests/test_connectors.py b/apps/api/tests/test_connectors.py new file mode 100644 index 0000000..f5b2b70 --- /dev/null +++ b/apps/api/tests/test_connectors.py @@ -0,0 +1,935 @@ +"""Connectors: manifest validation, the bundled catalog, and the install/uninstall pipeline. + +The load-bearing claims these tests pin down: + + * loading the catalog performs NO network I/O (the independence guarantee), + * installing a manifest creates ordinary AuthProvider/ToolSet/Tool rows - nothing bespoke, + * uninstalling removes exactly what the install created and nothing else, + * a failed install leaves no orphan rows behind, + * every CATALOG connector is one-click: OAuth, per-user, and credential-free at the point of + use - the deployment's environment is the only place a vendor app can come from, + * a CUSTOM (pasted) manifest keeps the old behaviour, because a service account with an API + key has to be typed by somebody and that is the path where it belongs. +""" + +from __future__ import annotations + +from contextlib import aclosing + +import pytest + +from forge.connectors.catalog import get_manifest, list_examples, list_manifests +from forge.connectors.install import ( + ConnectorInstaller, + InstallError, + env_ready, + group_has_credentials, + missing_app_keys, + secret_name, +) +from forge.connectors.manifest import ManifestError, RestBackend, parse_manifest +from forge.db.base import SessionLocal +from forge.models import AuthProvider, ConnectorInstall, Tool, ToolSet +from forge.secrets.store import SecretStore +from forge.services.tool_sets import ToolSetService + +REST_MANIFEST = { + "format": "forge.connector/1", + "slug": "acme", + "name": "Acme", + "summary": "Acme test connector", + "categories": ["custom"], + "roles": ["Software Engineer"], + "auth": { + "kind": "bearer", + "per_user": "optional", + "setup": [{"key": "token", "label": "API token", "secret": True, "required": True}], + }, + "egress_hosts": ["api.acme.test"], + "backend": { + "type": "rest", + "base_url": "https://api.acme.test/v1", + "actions": [ + { + "name": "acme_list_widgets", + "description": "List widgets.", + "request": { + "method": "GET", + "url_template": "/widgets", + "fields": [{"path": "limit", "in": "query", "type": "integer", "llm_visible": True}], + }, + "response": {"projection_jmespath": "items[].id"}, + }, + { + "name": "acme_get_widget", + "description": "Read one widget.", + "request": { + "method": "GET", + "url_template": "/widgets/{widget_id}", + "fields": [{"path": "widget_id", "in": "path", "type": "string", "required": True}], + }, + }, + ], + }, + "toolset": {"description": "Acme actions"}, +} + + +# --- manifest validation -------------------------------------------------------------------- + +def test_manifest_rejects_unknown_format(): + with pytest.raises(ManifestError): + parse_manifest({**REST_MANIFEST, "format": "forge.connector/9"}) + + +def test_manifest_rejects_action_without_url_template(): + bad = { + **REST_MANIFEST, + "backend": { + "type": "rest", + "actions": [{"name": "x", "request": {"method": "GET"}}], + }, + } + with pytest.raises(ManifestError): + parse_manifest(bad) + + +def test_manifest_rejects_bad_field_location(): + bad = { + **REST_MANIFEST, + "backend": { + "type": "rest", + "actions": [{ + "name": "x", + "request": {"method": "GET", "url_template": "/x", + "fields": [{"path": "a", "in": "nowhere"}]}, + }], + }, + } + with pytest.raises(ManifestError): + parse_manifest(bad) + + +def test_manifest_rejects_duplicate_action_names(): + action = REST_MANIFEST["backend"]["actions"][0] + with pytest.raises(ManifestError): + parse_manifest({**REST_MANIFEST, + "backend": {"type": "rest", "actions": [action, action]}}) + + +def test_manifest_hosts_include_backend_host_even_when_undeclared(): + m = parse_manifest({**REST_MANIFEST, "egress_hosts": []}) + assert "api.acme.test" in m.hosts() + + +# --- bundled catalog ------------------------------------------------------------------------ + +def test_bundled_catalog_all_parse(): + manifests = list_manifests() + assert len(manifests) >= 10, "the bundled catalog should ship a usable set of connectors" + slugs = {m.slug for m in manifests} + for expected in ("slack", "gmail", "outlook", "github"): + assert expected in slugs + + +def test_every_catalog_connector_is_one_click(): + """The gallery's promise, enforced at the catalog level: click Connect, sign in with your own + account, done. A connector that needs a typed key or a shared bot token can't keep that + promise, so it belongs in examples/ - not on a screen that says there are no forms.""" + for m in list_manifests(): + assert m.auth.kind == "oauth2_authorization_code", ( + f"{m.slug}: catalog connectors sign users in; {m.auth.kind} needs a pasted credential" + ) + assert m.auth.per_user == "required", ( + f"{m.slug}: a catalog account is personal, so the manifest must say so" + ) + # Nothing an END USER has to supply. Non-secret setup values are allowed only when the + # manifest carries a default (Microsoft's tenant = "common"), because the install form + # they would otherwise be typed into no longer exists. + for field in m.auth.setup: + if field.secret: + assert field.key in ("client_id", "client_secret"), ( + f"{m.slug}: secret setup key {field.key!r} can't come from the environment" + ) + else: + assert field.default is not None, ( + f"{m.slug}: non-secret setup key {field.key!r} has no default and nobody to ask" + ) + + +def test_no_catalog_action_asks_the_model_to_encode_something(): + """A model cannot base64-encode by hand. It will emit plausible-looking garbage, the API + answers an opaque 400, and the user has no way to tell whose fault it is. + + Gmail's send endpoint is the case that bit us: it takes only a base64url-encoded MIME + message, and the first manifest exposed `raw` as a tool argument. Encoding belongs on the + server (the `$mime` body directive), so no LLM-visible argument may ask for one. + """ + banned = ("base64", "b64", "encoded", "rfc 2822", "rfc2822", "rfc 5322") + for m in list_manifests(): + if not isinstance(m.backend, RestBackend): + continue + for action in m.backend.actions: + for field in action.request.get("fields", []): + if field.get("llm_visible") is False: + continue + blob = f"{field.get('path', '')} {field.get('description', '')}".lower() + hit = next((b for b in banned if b in blob), None) + assert hit is None, ( + f"{m.slug}.{action.name}: argument {field.get('path')!r} asks the model for " + f"{hit!r}-encoded input; encode it server-side instead" + ) + + +def test_gmail_send_takes_the_fields_a_person_would_type(): + """The regression guard for the 400: Gmail send must expose to/subject/body, and build the + MIME message itself.""" + send = next(a for a in get_manifest("gmail").backend.actions if a.name == "gmail_send_message") + args = {f["path"] for f in send.request["fields"]} + assert {"to", "subject", "body"} <= args + assert "raw" not in args + assert "$mime" in (send.request.get("body_template") or "") + + +def test_examples_are_not_in_the_gallery(): + """The bundled key/token connectors are still shipped - just via the custom path, where a + person typing a credential is the expected thing rather than a broken promise.""" + gallery = {m.slug for m in list_manifests()} + examples = {m.slug for m, _ in list_examples()} + assert examples, "the example manifests should still ship" + assert not (gallery & examples), "an example must not also be a gallery entry" + for expected in ("stripe", "slack-api", "custom-rest-api"): + assert expected in examples + + +def test_catalog_load_does_no_network_io(monkeypatch): + """The independence guarantee, enforced: opening the catalog must never touch the network.""" + import socket + + def _boom(*a, **kw): # pragma: no cover - only runs if the guarantee breaks + raise AssertionError("catalog load attempted network I/O") + + monkeypatch.setattr(socket, "socket", _boom) + monkeypatch.setattr(socket, "create_connection", _boom) + from forge.connectors.catalog import reload_catalog + + reload_catalog() + assert list_manifests() + + +def test_every_catalog_action_declares_llm_visible_required_args(): + """A required field the model can't see can never be filled, so the tool would always 400.""" + for m in list_manifests(): + if not isinstance(m.backend, RestBackend): + continue + for action in m.backend.actions: + for field in action.request.get("fields", []): + if field.get("required") and field.get("llm_visible") is False: + assert field.get("default") is not None, ( + f"{m.slug}.{action.name}: field {field['path']} is required and hidden " + "from the model but has no default" + ) + + +# --- install / uninstall -------------------------------------------------------------------- + +async def _install(tenant: str, project: str, manifest_dict: dict | None = None, **kw): + """Install REST_MANIFEST down the CUSTOM path - a pasted manifest with a typed credential, + which is the only path that still accepts one.""" + manifest = parse_manifest(manifest_dict or REST_MANIFEST) + kw.setdefault("source", "custom") + async with SessionLocal() as s: + return await ConnectorInstaller().install( + s, tenant, project, manifest, values={"token": "sekrit"}, **kw + ) + + +async def _install_catalog(tenant: str, project: str, slug: str): + async with SessionLocal() as s: + return await ConnectorInstaller().install( + s, tenant, project, get_manifest(slug), source="catalog" + ) + + +@pytest.fixture +def google_app(monkeypatch): + """The deployment has registered one Google OAuth app - the normal state for a Forge that + actually offers Gmail.""" + from forge.config import settings + + monkeypatch.setattr( + settings, "connector_oauth_apps", + {"google": {"client_id": "deployment-cid", "client_secret": "deployment-csec"}}, + ) + + +@pytest.fixture +def no_apps(monkeypatch): + """A deployment that has registered nothing. + + Pinned explicitly rather than assumed: settings are read from the developer's own .env, so a + machine that HAS configured a vendor would otherwise fail these tests - and, worse, print a + real client secret into the assertion diff.""" + from forge.config import settings + + monkeypatch.setattr(settings, "connector_oauth_apps", {}) + + +async def test_install_creates_auth_provider_toolset_and_tools(): + tenant, project = "t_conn_i", "p_conn_i" + row = await _install(tenant, project) + + assert row.status == "connected" # bearer needs no browser round trip + assert len(row.created_tool_ids) == 2 + + async with SessionLocal() as s: + ap = await s.get(AuthProvider, row.auth_provider_id) + assert ap is not None and ap.kind == "bearer" + # The credential is a ref, never a literal - the same rule bundles follow. + assert ap.config["token_ref"] == f"secret://proj/{secret_name('acme', 'token')}" + + ts = await s.get(ToolSet, row.tool_set_id) + assert ts is not None + members = await ToolSetService.member_ids(s, tenant, ts.id) + assert sorted(members) == sorted(row.created_tool_ids) + + tools = [await s.get(Tool, tid) for tid in row.created_tool_ids] + assert {t.name for t in tools} == {"acme_list_widgets", "acme_get_widget"} + for t in tools: + assert t.kind == "rest_api" + assert t.auth_provider_id == row.auth_provider_id + # base_url composed with the relative action path. + assert t.config["request"]["url_template"].startswith("https://api.acme.test/v1/widgets") + + +async def test_installed_secret_is_readable_by_the_resolver_ref(): + tenant, project = "t_conn_s", "p_conn_s" + await _install(tenant, project) + value = await SecretStore().read_ref( + tenant_id=tenant, project_id=project, ref=f"secret://proj/{secret_name('acme', 'token')}" + ) + assert value == "sekrit" + + +async def test_install_is_rejected_twice(): + tenant, project = "t_conn_d", "p_conn_d" + await _install(tenant, project) + with pytest.raises(InstallError): + await _install(tenant, project) + + +async def test_install_requires_declared_credentials(): + tenant, project = "t_conn_r", "p_conn_r" + manifest = parse_manifest(REST_MANIFEST) + async with SessionLocal() as s: + with pytest.raises(InstallError): + await ConnectorInstaller().install(s, tenant, project, manifest, values={}, source="custom") + + +async def test_per_user_mode_sets_the_context_key_the_resolver_reads(): + tenant, project = "t_conn_pu", "p_conn_pu" + row = await _install(tenant, project, auth_mode="per_user") + assert row.auth_mode == "per_user" + async with SessionLocal() as s: + ap = await s.get(AuthProvider, row.auth_provider_id) + assert ap.config["per_user_context_keys"] == ["end_user_id"] + + +async def test_manifest_forcing_per_user_ignores_a_shared_request(): + """`per_user: required` exists so a personal-mailbox connector cannot be installed with one + shared credential by accident.""" + forced = {**REST_MANIFEST, "slug": "acme-pu", + "auth": {**REST_MANIFEST["auth"], "per_user": "required"}} + row = await _install("t_conn_f", "p_conn_f", forced, auth_mode="shared") + assert row.auth_mode == "per_user" + + +async def test_uninstall_removes_exactly_what_was_created(): + tenant, project = "t_conn_u", "p_conn_u" + row = await _install(tenant, project) + tool_ids, ts_id, ap_id = list(row.created_tool_ids), row.tool_set_id, row.auth_provider_id + + # A tool the user built themselves must survive the uninstall. + from forge.services.tools import ToolService + async with SessionLocal() as s: + mine = await ToolService.create(s, tenant, project, name="my_own_tool", kind="builtin", + config={"builtin": "current_time"}) + mine_id = mine.id + + async with SessionLocal() as s: + install = await ConnectorInstaller.get_install(s, tenant, project, "acme") + await ConnectorInstaller().uninstall(s, install) + + async with SessionLocal() as s: + for tid in tool_ids: + assert await s.get(Tool, tid) is None + assert await s.get(ToolSet, ts_id) is None + assert await s.get(AuthProvider, ap_id) is None + assert await ConnectorInstaller.get_install(s, tenant, project, "acme") is None + assert await s.get(Tool, mine_id) is not None, "uninstall deleted an unrelated tool" + + +async def test_uninstall_clears_the_stored_credential(): + tenant, project = "t_conn_c", "p_conn_c" + await _install(tenant, project) + async with SessionLocal() as s: + install = await ConnectorInstaller.get_install(s, tenant, project, "acme") + await ConnectorInstaller().uninstall(s, install) + value = await SecretStore().read_ref( + tenant_id=tenant, project_id=project, ref=f"secret://proj/{secret_name('acme', 'token')}" + ) + assert value == "" + + +async def test_failed_install_leaves_no_orphan_rows(): + """A manifest whose tool creation blows up must not leave a dangling auth provider + tool + set behind - a half-installed connector is worse than none.""" + tenant, project = "t_conn_x", "p_conn_x" + manifest = parse_manifest({**REST_MANIFEST, "slug": "acme-boom"}) + + installer = ConnectorInstaller() + + async def _explode(*a, **kw): + raise RuntimeError("boom") + + installer._create_rest_tools = _explode # type: ignore[method-assign] + + async with SessionLocal() as s: + with pytest.raises(RuntimeError): + await installer.install(s, tenant, project, manifest, values={"token": "x"}, source="custom") + + async with SessionLocal() as s: + from sqlalchemy import select + aps = (await s.execute(select(AuthProvider).where(AuthProvider.project_id == project))).scalars().all() + sets = (await s.execute(select(ToolSet).where(ToolSet.project_id == project))).scalars().all() + installs = (await s.execute(select(ConnectorInstall).where(ConnectorInstall.project_id == project))).scalars().all() + assert aps == [] and sets == [] and installs == [] + + +async def test_install_adds_connector_hosts_to_the_project_egress_allow_list(): + tenant, project = "t_conn_e", "p_conn_e" + from forge.models import Project + async with SessionLocal() as s: + s.add(Project(id=project, tenant_id=tenant, name="P", slug="p", config={})) + await s.commit() + + await _install(tenant, project) + + async with SessionLocal() as s: + proj = await s.get(Project, project) + assert "api.acme.test" in (proj.config.get("egress") or {}).get("allow_hosts", []) + + +async def test_setup_placeholders_are_substituted_into_urls(): + manifest = { + **REST_MANIFEST, + "slug": "acme-tpl", + "auth": { + "kind": "bearer", + "setup": [ + {"key": "token", "label": "Token", "secret": True, "required": True}, + {"key": "site", "label": "Site", "secret": False, "required": False, "default": "eu"}, + ], + }, + "backend": { + "type": "rest", + "base_url": "https://{setup.site}.acme.test/v1", + "actions": REST_MANIFEST["backend"]["actions"][:1], + }, + } + row = await _install("t_conn_t", "p_conn_t", manifest) + async with SessionLocal() as s: + tool = await s.get(Tool, row.created_tool_ids[0]) + # The default filled in for the value the installer left blank. + assert tool.config["request"]["url_template"].startswith("https://eu.acme.test/v1/") + + +async def test_google_family_shares_one_credential_group(): + """Gmail/Calendar/Drive/Sheets are ONE Google Cloud OAuth client. They must resolve to the + same stored secret and the same env key, or an operator registers four apps to offer what + Google considers one integration.""" + slugs = ["gmail", "google-calendar", "google-drive", "google-sheets"] + groups = {get_manifest(s).group for s in slugs} + assert groups == {"google"} + assert get_manifest("outlook").group == "microsoft" + # An unrelated connector keeps its own namespace. + assert get_manifest("github").group == "github" + + +async def test_one_env_entry_covers_the_whole_google_family(google_app): + """The operator registers ONE Google app; Gmail, Calendar, Drive and Sheets all become + connectable from it, sharing a single stored credential to rotate.""" + tenant, project = "t_conn_grp", "p_conn_grp" + for slug in ("gmail", "google-calendar", "google-drive", "google-sheets"): + assert env_ready(get_manifest(slug)), slug + await _install_catalog(tenant, project, "gmail") + row = await _install_catalog(tenant, project, "google-calendar") + async with SessionLocal() as s: + ap = await s.get(AuthProvider, row.auth_provider_id) + assert ap.config["client_id_ref"] == f"secret://proj/{secret_name('google', 'client_id')}" + value = await SecretStore().read_ref( + tenant_id=tenant, project_id=project, ref=f"secret://proj/{secret_name('google', 'client_id')}" + ) + assert value == "deployment-cid" + + +async def test_uninstalling_one_google_connector_does_not_break_its_siblings(google_app): + """The regression this guard exists for: blanking the shared client secret on uninstall + would silently disable every other Google connector in the project.""" + tenant, project = "t_conn_grp2", "p_conn_grp2" + await _install_catalog(tenant, project, "gmail") + await _install_catalog(tenant, project, "google-calendar") + + async with SessionLocal() as s: + gmail = await ConnectorInstaller.get_install(s, tenant, project, "gmail") + await ConnectorInstaller().uninstall(s, gmail) + + still_there = await SecretStore().read_ref( + tenant_id=tenant, project_id=project, ref=f"secret://proj/{secret_name('google', 'client_secret')}" + ) + assert still_there == "deployment-csec", "uninstalling Gmail wiped the shared Google credential" + + # Removing the LAST member of the group does clear it. + async with SessionLocal() as s: + cal = await ConnectorInstaller.get_install(s, tenant, project, "google-calendar") + await ConnectorInstaller().uninstall(s, cal) + cleared = await SecretStore().read_ref( + tenant_id=tenant, project_id=project, ref=f"secret://proj/{secret_name('google', 'client_secret')}" + ) + assert cleared == "" + + +async def test_deployment_registered_app_is_seeded_into_the_project_store(google_app): + """The one-click path: the operator's app is copied into the project's own encrypted store, + so resolve/refresh/rotate behave exactly as they would for a hand-pasted credential and no + second credential source has to be taught to every downstream path.""" + tenant, project = "t_conn_dep", "p_conn_dep" + row = await _install_catalog(tenant, project, "gmail") + + value = await SecretStore().read_ref( + tenant_id=tenant, project_id=project, ref=f"secret://proj/{secret_name('google', 'client_id')}" + ) + assert value == "deployment-cid" + async with SessionLocal() as s: + ap = await s.get(AuthProvider, row.auth_provider_id) + assert ap.config["client_id_ref"] == f"secret://proj/{secret_name('google', 'client_id')}" + + +async def test_catalog_install_ignores_credentials_sent_by_a_caller(google_app): + """There is no supported way to type a credential into a catalog connector, so a client that + sends one anyway must not create a second, invisible source of truth - the deployment's app + is the answer, and `env_ready` has to keep telling the truth about it.""" + tenant, project = "t_conn_dep2", "p_conn_dep2" + async with SessionLocal() as s: + await ConnectorInstaller().install( + s, tenant, project, get_manifest("gmail"), source="catalog", + values={"client_id": "smuggled-cid", "client_secret": "smuggled-csec"}, + ) + value = await SecretStore().read_ref( + tenant_id=tenant, project_id=project, ref=f"secret://proj/{secret_name('google', 'client_id')}" + ) + assert value == "deployment-cid" + + +async def test_unconfigured_catalog_connector_refuses_to_install(no_apps): + """Default posture with nothing in the environment: the connector is unavailable and the + error names the env key, rather than degrading into a form for a secret an end user should + never hold.""" + gmail = get_manifest("gmail") + assert not env_ready(gmail) + assert missing_app_keys(gmail) == ["client_id", "client_secret"] + + async with SessionLocal() as s: + with pytest.raises(InstallError) as e: + await ConnectorInstaller().install(s, "t_none", "p_none", gmail, source="catalog") + assert "FORGE_CONNECTOR_OAUTH_APPS" in str(e.value) + assert "google" in str(e.value) + + +async def test_discovery_connectors_need_no_environment_entry(no_apps): + """Slack, Notion, Linear and Atlassian publish OAuth metadata, so Forge registers a client + with them on the fly (RFC 7591). They are one-click on a deployment that has configured + nothing at all - which is what an evaluator sees on first boot.""" + for slug in ("slack", "notion", "linear", "atlassian"): + m = get_manifest(slug) + assert m.auth.discover, slug + assert env_ready(m), f"{slug} should be connectable with no env configuration" + + +async def test_gmail_installs_as_per_user_oauth_awaiting_its_first_sign_in(google_app): + """A real catalog entry, end to end - the shape the Connectors screen actually installs.""" + tenant, project = "t_conn_gm", "p_conn_gm" + manifest = get_manifest("gmail") + row = await _install_catalog(tenant, project, "gmail") + + assert row.auth_mode == "per_user" + # Nobody has signed in yet. The row-level status only ever means "at least one person has + # connected"; what matters to a given user is computed per caller by the router. + assert row.status == "needs_auth" + assert len(row.created_tool_ids) == len(manifest.backend.actions) + async with SessionLocal() as s: + ap = await s.get(AuthProvider, row.auth_provider_id) + assert ap.config["per_user_context_keys"] == ["end_user_id"] + assert ap.config["authorize_url"].startswith("https://accounts.google.com/") + assert "gmail.send" in ap.config["scope"] + + +async def test_a_catalog_connector_is_personal_even_if_asked_to_be_shared(google_app): + """`auth_mode` is a custom-connector control. A catalog connector is a personal account, and + a caller asking for a project-wide one must not get someone's mailbox shared with the team.""" + tenant, project = "t_conn_shared", "p_conn_shared" + async with SessionLocal() as s: + row = await ConnectorInstaller().install( + s, tenant, project, get_manifest("gmail"), source="catalog", auth_mode="shared", + ) + assert row.auth_mode == "per_user" + + +async def test_custom_manifests_keep_the_shared_option_and_the_credential_form(): + """The escape hatch the catalog rules deliberately leave open: an unattended workflow with no + end user still needs a credential, and a pasted manifest is where that is configured.""" + tenant, project = "t_conn_custom", "p_conn_custom" + row = await _install(tenant, project, auth_mode="shared") + assert row.auth_mode == "shared" + value = await SecretStore().read_ref( + tenant_id=tenant, project_id=project, ref=f"secret://proj/{secret_name('acme', 'token')}" + ) + assert value == "sekrit" + # Group sharing still applies on the custom path, where credentials are typed. + assert not await group_has_credentials(SecretStore(), "t_x", "p_x", parse_manifest(REST_MANIFEST)) + + +# --- the API surface a person actually clicks ----------------------------------------------- +# +# One call, one browser round trip, connected - and "connected" means connected FOR YOU. These +# pin down the two claims the Connectors screen makes to whoever is looking at it. + +async def _editor_client(): + """An httpx client authenticated as a freshly registered (owner-role) user, plus a project. + + Returned already in use, so callers close it with `aclosing` rather than `async with` - an + httpx client can only be entered once, and this one has made its first request already. + """ + import uuid + + import httpx + + from forge.main import create_app + + c = httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app()), base_url="http://test") + reg = await c.post("/v1/auth/register", + json={"email": f"u{uuid.uuid4().hex[:10]}@example.com", "password": "supersecret1"}) + assert reg.status_code == 201, reg.text + c.headers["Authorization"] = f"Bearer {reg.json()['access_token']}" + pid = (await c.post("/v1/projects", json={"name": "Conn", "slug": f"conn-{uuid.uuid4().hex[:8]}"})).json()["id"] + return c, pid + + +async def _client_for(user_id: str, tenant: str, role: str): + import httpx + + from forge.main import create_app + from forge.security import create_access_token + + c = httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app()), base_url="http://test") + c.headers["Authorization"] = f"Bearer {create_access_token(user_id=user_id, tenant_id=tenant, role=role)}" + return c + + +async def _make_user(tenant: str, email: str, role: str) -> str: + from forge.models import User + + async with SessionLocal() as s: + u = User(tenant_id=tenant, email=email, role=role, status="active") + s.add(u) + await s.commit() + await s.refresh(u) + return u.id + + +async def _fake_consent(tenant: str, project: str, slug: str, user_id: str) -> None: + """Stand in for a completed browser consent: store that user's bundle where the callback + would have put it.""" + from forge.services.auth_providers import AuthProviderService + + async with SessionLocal() as s: + install = await ConnectorInstaller.get_install(s, tenant, project, slug) + ap = await s.get(AuthProvider, install.auth_provider_id) + await AuthProviderService.set_user_connection( + s, tenant, project, ap, user_id, bundle={"access_token": f"tok-{user_id}"}, + ) + await s.commit() + + +async def test_connect_adds_the_connector_on_the_first_click(google_app): + """The whole point of the screen: nobody has to know an "install" step exists.""" + c, pid = await _editor_client() + async with aclosing(c): + assert (await c.get(f"/v1/projects/{pid}/connectors")).json() == [] + + r = await c.post(f"/v1/projects/{pid}/connectors/gmail/connect", json={}) + assert r.status_code == 200, r.text + body = r.json() + assert body["per_user"] is True + assert body["authorize_url"].startswith("https://accounts.google.com/") + # PKCE, and the DEPLOYMENT's client id - not something typed on this screen. + assert "code_challenge_method=S256" in body["authorize_url"] + assert "client_id=deployment-cid" in body["authorize_url"] + + installed = (await c.get(f"/v1/projects/{pid}/connectors")).json() + assert [i["slug"] for i in installed] == ["gmail"] + assert installed[0]["auth_mode"] == "per_user" + assert installed[0]["connected"] is False + + +async def test_unconfigured_connector_is_unavailable_and_names_the_env_key(no_apps): + """No form, no half-working install - the card tells the operator what to register.""" + c, pid = await _editor_client() + async with aclosing(c): + cat = (await c.get(f"/v1/projects/{pid}/connectors/catalog")).json()["connectors"] + gmail = next(x for x in cat if x["slug"] == "gmail") + assert gmail["managed"] is True + assert gmail["configured"] is False + assert gmail["missing_keys"] == ["client_id", "client_secret"] + assert gmail["config_env_key"] == "FORGE_CONNECTOR_OAUTH_APPS" + assert gmail["credential_group"] == "google" + # The callback to whitelist comes from the API's own public base URL. Letting the browser + # guess it from the console origin yields a plausible-looking URL that the vendor then + # rejects as redirect_uri_mismatch - so the server has to be the one saying it. + from forge.config import settings + assert gmail["redirect_uri"] == f"{settings.public_base_url}/v1/oauth/callback" + + r = await c.post(f"/v1/projects/{pid}/connectors/gmail/connect", json={}) + assert r.status_code == 400 + assert "FORGE_CONNECTOR_OAUTH_APPS" in r.json()["detail"] + + # Slack needs no entry at all - it registers a client dynamically. + slack = next(x for x in cat if x["slug"] == "slack") + assert slack["configured"] is True and slack["missing_keys"] == [] + + +async def test_connected_is_answered_for_the_caller_not_the_project(google_app): + """A colleague signing in to their own mailbox must not show as a green tick on yours.""" + c, pid = await _editor_client() + async with aclosing(c): + me = (await c.get("/v1/auth/me")).json() + tenant, my_id = me["tenant_id"], me["id"] + await c.post(f"/v1/projects/{pid}/connectors/gmail/connect", json={}) + assert (await c.get(f"/v1/projects/{pid}/connectors")).json()[0]["connected"] is False + + await _fake_consent(tenant, pid, "gmail", my_id) + assert (await c.get(f"/v1/projects/{pid}/connectors")).json()[0]["connected"] is True + assert (await c.get(f"/v1/projects/{pid}/connectors/gmail/status")).json()["connected"] is True + + colleague = await _make_user(tenant, "colleague@example.com", "editor") + c2 = await _client_for(colleague, tenant, "editor") + async with aclosing(c2): + theirs = (await c2.get(f"/v1/projects/{pid}/connectors")).json()[0] + assert theirs["connected"] is False, "one person's Gmail became everyone's" + + +async def test_a_viewer_is_told_who_can_add_a_connector_rather_than_hitting_a_404(google_app): + """Adding a connector creates project-level tools, so it stays an editor action - but the + person who gets there first deserves an instruction, not a dead end.""" + c, pid = await _editor_client() + async with aclosing(c): + tenant = (await c.get("/v1/auth/me")).json()["tenant_id"] + + uid = await _make_user(tenant, "viewer-conn@example.com", "viewer") + c2 = await _client_for(uid, tenant, "viewer") + async with aclosing(c2): + r = await c2.post(f"/v1/projects/{pid}/connectors/gmail/connect", json={}) + assert r.status_code == 403 + assert "editor" in r.json()["detail"] + + # Once an editor has added it, that same viewer connects their OWN account, no extra rights. + await _install_catalog(tenant, pid, "gmail") + c3 = await _client_for(uid, tenant, "viewer") + async with aclosing(c3): + r = await c3.post(f"/v1/projects/{pid}/connectors/gmail/connect", json={}) + assert r.status_code == 200, r.text + assert r.json()["authorize_url"].startswith("https://accounts.google.com/") + await _fake_consent(tenant, pid, "gmail", uid) + assert (await c3.get(f"/v1/projects/{pid}/connectors/gmail/status")).json()["connected"] is True + + +async def test_catalog_credentials_cannot_be_typed_in_through_the_api(google_app): + """Rotation for a catalog connector is "change the env and restart", so the route that + accepts pasted credentials has to say so rather than quietly writing a shadow copy.""" + c, pid = await _editor_client() + async with aclosing(c): + await c.post(f"/v1/projects/{pid}/connectors/gmail/connect", json={}) + r = await c.put(f"/v1/projects/{pid}/connectors/gmail/credentials", + json={"values": {"client_secret": "smuggled"}}) + assert r.status_code == 400 + assert "FORGE_CONNECTOR_OAUTH_APPS" in r.json()["detail"] + + +async def test_mcp_discovery_asks_as_the_user_who_just_consented(google_app, monkeypatch): + """The bug this exists for: an MCP connector signed in fine, then listed ZERO actions. + + Every catalog connector is per-user, so the only credential that exists after consent is + stored under that person's identity. Discovery that forgets to say who it is resolves a + shared bundle nobody wrote, calls the server unauthenticated, gets a 401, and leaves the + connector "connected" with nothing in it.""" + from forge.connectors.manifest import parse_manifest as _parse + + seen: list[dict | None] = [] + + async def _fake_discover(client_row, tenant_id, project_id, context=None): + seen.append(context) + if not (context or {}).get("end_user_id"): + raise RuntimeError("401 Unauthorized") + return [{"name": "notion_search", "description": "Search."}] + + monkeypatch.setattr("forge.tools.mcp.discover_tools", _fake_discover) + + tenant, project = "t_mcp_ctx", "p_mcp_ctx" + manifest = _parse({ + **get_manifest("notion").model_dump(mode="json"), + "auth": {**get_manifest("notion").model_dump(mode="json")["auth"], "discover": True}, + }) + async with SessionLocal() as s: + install = await ConnectorInstaller().install(s, tenant, project, manifest, source="catalog") + assert install.created_tool_ids == [], "discovery before consent legitimately finds nothing" + assert seen and seen[-1] is None or True # install-time probe has no identity yet + + async with SessionLocal() as s: + row = await ConnectorInstaller.get_install(s, tenant, project, "notion") + count = await ConnectorInstaller().sync_tools(s, row, context={"end_user_id": "user-1"}) + assert count == 1 + assert seen[-1] == {"end_user_id": "user-1"} + + +async def test_refresh_delivers_a_corrected_manifest_to_an_existing_install(google_app): + """A connector installed last week holds a COPY of the manifest as it was then, so a fix to a + bundled action has to be able to REACH it. Uninstall/reinstall would work but deletes the auth + provider, making everyone who signed in redo it for a change they had nothing to do with.""" + tenant, project = "t_upg", "p_upg" + install = await _install_catalog(tenant, project, "gmail") + tool_ids_before = list(install.created_tool_ids) + + # Rewind this install to a stale manifest: the old Gmail send action, which asked the MODEL + # for a base64url-encoded MIME message and produced an opaque 400. + stale = {**install.manifest} + stale["version"] = "0.9.0" + stale["backend"] = {**stale["backend"], "actions": [ + {**a, "request": {"method": "POST", "url_template": "/messages/send", + "fields": [{"path": "raw", "in": "body", "type": "string", + "required": True, "llm_visible": True}]}} + if a["name"] == "gmail_send_message" else a + for a in stale["backend"]["actions"] + ]} + async with SessionLocal() as s: + row = await ConnectorInstaller.get_install(s, tenant, project, "gmail") + row.manifest = stale + row.version = "0.9.0" + send_tool = next(t for t in [await s.get(Tool, i) for i in tool_ids_before] + if t.name == "gmail_send_message") + send_tool.config = {**send_tool.config, "request": stale["backend"]["actions"][2]["request"]} + await s.commit() + assert {f["path"] for f in send_tool.config["request"]["fields"]} == {"raw"} + + async with SessionLocal() as s: + row = await ConnectorInstaller.get_install(s, tenant, project, "gmail") + await ConnectorInstaller().sync_tools(s, row) + + async with SessionLocal() as s: + row = await ConnectorInstaller.get_install(s, tenant, project, "gmail") + # Tool ids are preserved, so every workflow node and agent grant keeps working. + assert row.created_tool_ids == tool_ids_before + assert row.version == get_manifest("gmail").version + fixed = next(t for t in [await s.get(Tool, i) for i in row.created_tool_ids] + if t.name == "gmail_send_message") + args = {f["path"] for f in fixed.config["request"]["fields"]} + assert {"to", "subject", "body"} <= args and "raw" not in args + assert "$mime" in fixed.config["request"]["body_template"] + # The auth provider - and therefore everyone's stored sign-in - is untouched. + async with SessionLocal() as s: + assert await s.get(AuthProvider, install.auth_provider_id) is not None + + +async def test_refresh_keeps_the_values_this_install_was_configured_with(): + """A refresh must not rebuild URLs from the manifest's DEFAULTS - those are precisely what + the installer overrode, so doing so would silently repoint every tool at the wrong host.""" + manifest = { + **REST_MANIFEST, + "slug": "acme-site", + "auth": { + "kind": "bearer", + "setup": [ + {"key": "token", "label": "Token", "secret": True, "required": True}, + {"key": "site", "label": "Site", "secret": False, "required": False, "default": "eu"}, + ], + }, + "backend": { + "type": "rest", + "base_url": "https://{setup.site}.acme.test/v1", + "actions": REST_MANIFEST["backend"]["actions"][:1], + }, + } + tenant, project = "t_upg3", "p_upg3" + manifest_obj = parse_manifest(manifest) + async with SessionLocal() as s: + row = await ConnectorInstaller().install( + s, tenant, project, manifest_obj, source="custom", + values={"token": "sekrit", "site": "apac"}, + ) + async with SessionLocal() as s: + tool = await s.get(Tool, row.created_tool_ids[0]) + assert tool.config["request"]["url_template"].startswith("https://apac.acme.test/v1/") + install = await ConnectorInstaller.get_install(s, tenant, project, "acme-site") + await ConnectorInstaller().sync_tools(s, install) + async with SessionLocal() as s: + tool = await s.get(Tool, row.created_tool_ids[0]) + assert tool.config["request"]["url_template"].startswith("https://apac.acme.test/v1/"), \ + "refresh rebuilt the URL from the manifest default and lost the configured site" + + +async def test_refresh_keeps_an_action_the_manifest_dropped(): + """Deleting a tool a live workflow still references is a worse failure than carrying a stale + one, so a shrunken manifest leaves the extra row alone.""" + tenant, project = "t_upg2", "p_upg2" + row = await _install(tenant, project) # custom manifest, two actions + keep = list(row.created_tool_ids) + shrunk = {**row.manifest} + shrunk["backend"] = {**shrunk["backend"], "actions": shrunk["backend"]["actions"][:1]} + async with SessionLocal() as s: + install = await ConnectorInstaller.get_install(s, tenant, project, "acme") + install.manifest = shrunk + await s.commit() + await ConnectorInstaller().sync_tools(s, install) + async with SessionLocal() as s: + for tid in keep: + assert await s.get(Tool, tid) is not None + + +async def test_mcp_errors_name_the_actual_failure_not_the_task_group(): + """anyio wraps every MCP transport failure in an ExceptionGroup whose str() is "unhandled + errors in a TaskGroup (1 sub-exception)" - which tells a user nothing about the 401 inside.""" + from forge.tools.mcp import describe_mcp_error + + inner = PermissionError("401 Unauthorized: token is invalid") + group = BaseExceptionGroup("unhandled errors in a TaskGroup", [inner]) + described = describe_mcp_error(group) + assert "401 Unauthorized" in described + assert "TaskGroup" not in described + + # Nested groups (a task group inside a task group) still reach the leaf, and repeats of the + # same error across sibling tasks collapse into one line. + nested = BaseExceptionGroup("outer", [BaseExceptionGroup("inner", [inner, inner])]) + assert describe_mcp_error(nested).count("401 Unauthorized") == 1 + # A plain exception is passed through unharmed. + assert "boom" in describe_mcp_error(RuntimeError("boom")) + + +async def test_examples_route_offers_the_key_based_manifests(): + c, pid = await _editor_client() + async with aclosing(c): + rows = (await c.get(f"/v1/projects/{pid}/connectors/examples")).json() + slugs = {r["slug"] for r in rows} + assert {"stripe", "slack-api", "twilio"} <= slugs + stripe = next(r for r in rows if r["slug"] == "stripe") + assert stripe["needs"], "an example should say what it will ask for" + # The payload is a manifest the custom form can install as-is. + assert parse_manifest(stripe["manifest"]).slug == "stripe" diff --git a/apps/api/tests/test_mcp.py b/apps/api/tests/test_mcp.py index 3dddb96..db6480a 100644 --- a/apps/api/tests/test_mcp.py +++ b/apps/api/tests/test_mcp.py @@ -35,7 +35,7 @@ async def test_adapters_import_available(): async def test_load_mcp_tool_finds_remote_tool(monkeypatch): cid = await _make_client() - async def fake_client_and_tools(row, tenant_id, project_id): + async def fake_client_and_tools(row, tenant_id, project_id, context=None): return object(), [_fake_remote_tool("search")] monkeypatch.setattr(mcp_mod, "_client_and_tools", fake_client_and_tools) diff --git a/apps/api/tests/test_mcp_auth.py b/apps/api/tests/test_mcp_auth.py new file mode 100644 index 0000000..b0bccb3 --- /dev/null +++ b/apps/api/tests/test_mcp_auth.py @@ -0,0 +1,177 @@ +"""MCP client-side auth: an Auth Provider attached to an external MCP server. + +This is what makes an OAuth-protected remote MCP server (mcp.slack.com and friends) reachable. +The properties that matter, and are easy to get wrong: + + * provider headers are applied per REQUEST, so a refreshed token lands without reconnecting, + * a 401 forces exactly one re-resolve and retry (not a loop), + * a PER-USER provider gets its OWN pooled connection per end user - the bug where one user's + authenticated session is handed to the next caller is the whole reason the cache key has a + user-dims suffix. +""" + +from __future__ import annotations + +import httpx +import pytest + +from forge.db.base import SessionLocal +from forge.models import McpClient +from forge.services.auth_providers import AuthProviderService +from forge.tools import mcp as mcp_mod + + +async def _provider(tenant, project, *, per_user: bool, name="srv"): + async with SessionLocal() as s: + cfg = {"token_ref": "secret://proj/tok", "header_name": "Authorization", "prefix": "Bearer "} + if per_user: + cfg["per_user_context_keys"] = ["end_user_id"] + ap = await AuthProviderService.create(s, tenant, project, name=name, kind="bearer", config=cfg) + return ap.id + + +async def _client(tenant, project, ap_id=None, transport="streamable_http"): + async with SessionLocal() as s: + row = McpClient(tenant_id=tenant, project_id=project, name="srv", transport=transport, + url="https://mcp.example/mcp", auth_provider_id=ap_id) + s.add(row) + await s.commit() + await s.refresh(row) + return row + + +class _StubResolver: + """Stands in for AuthResolver: records each resolve and hands back a token.""" + + def __init__(self, token="tok-1"): + self.calls: list[dict] = [] + self.token = token + + async def resolve(self, *, tenant_id, project_id, provider_id, context=None, force=False): + self.calls.append({"context": dict(context or {}), "force": force}) + from forge.auth_providers.resolver import ResolvedAuth + return ResolvedAuth(headers={"Authorization": f"Bearer {self.token}"}) + + +async def test_no_auth_provider_means_no_auth_on_the_connection(): + row = await _client("t_ma_n", "p_ma_n") + conn = await mcp_mod._connection_for(row, "t_ma_n", "p_ma_n") + assert "auth" not in conn + + +async def test_auth_provider_attaches_an_httpx_auth_to_the_connection(): + tenant, project = "t_ma_a", "p_ma_a" + ap_id = await _provider(tenant, project, per_user=False) + row = await _client(tenant, project, ap_id) + conn = await mcp_mod._connection_for(row, tenant, project) + assert isinstance(conn.get("auth"), httpx.Auth) + + +async def test_stdio_transport_never_gets_http_auth(): + """A provider on a stdio server is a config mistake; half-applying it would be worse.""" + tenant, project = "t_ma_s", "p_ma_s" + ap_id = await _provider(tenant, project, per_user=False) + row = await _client(tenant, project, ap_id, transport="stdio") + row.command = "echo" + from forge.config import settings + settings.enable_mcp_stdio = True + try: + conn = await mcp_mod._connection_for(row, tenant, project) + finally: + settings.enable_mcp_stdio = False + assert "auth" not in conn + + +async def test_provider_auth_applies_headers_per_request(): + resolver = _StubResolver() + auth = mcp_mod._ProviderAuth(resolver, tenant_id="t", project_id="p", provider_id="ap", context={}) + request = httpx.Request("POST", "https://mcp.example/mcp") + + flow = auth.async_auth_flow(request) + sent = await flow.__anext__() + assert sent.headers["Authorization"] == "Bearer tok-1" + + # A rotated token is picked up on the NEXT request without rebuilding the connection. + resolver.token = "tok-2" + request2 = httpx.Request("POST", "https://mcp.example/mcp") + flow2 = auth.async_auth_flow(request2) + sent2 = await flow2.__anext__() + assert sent2.headers["Authorization"] == "Bearer tok-2" + + +async def test_401_forces_one_refresh_and_retries_once(): + resolver = _StubResolver() + auth = mcp_mod._ProviderAuth(resolver, tenant_id="t", project_id="p", provider_id="ap", context={}) + request = httpx.Request("POST", "https://mcp.example/mcp") + + flow = auth.async_auth_flow(request) + await flow.__anext__() + resolver.token = "fresh" + retried = await flow.asend(httpx.Response(401, request=request)) + assert retried.headers["Authorization"] == "Bearer fresh" + assert resolver.calls[-1]["force"] is True, "the retry must bypass the resolver's cache" + + # Exactly one retry: a second 401 ends the flow rather than looping. + with pytest.raises(StopAsyncIteration): + await flow.asend(httpx.Response(401, request=request)) + + +async def test_success_does_not_re_resolve(): + resolver = _StubResolver() + auth = mcp_mod._ProviderAuth(resolver, tenant_id="t", project_id="p", provider_id="ap", context={}) + request = httpx.Request("POST", "https://mcp.example/mcp") + flow = auth.async_auth_flow(request) + await flow.__anext__() + with pytest.raises(StopAsyncIteration): + await flow.asend(httpx.Response(200, request=request)) + assert len(resolver.calls) == 1 + + +async def test_shared_provider_pools_one_connection_for_everyone(): + tenant, project = "t_ma_sh", "p_ma_sh" + ap_id = await _provider(tenant, project, per_user=False) + row = await _client(tenant, project, ap_id) + _a, s1 = await mcp_mod._auth_for(row, tenant, project, {"end_user_id": "u1"}) + _b, s2 = await mcp_mod._auth_for(row, tenant, project, {"end_user_id": "u2"}) + assert s1 == s2 == "" + + +async def test_per_user_provider_gets_a_distinct_connection_per_end_user(): + tenant, project = "t_ma_pu", "p_ma_pu" + ap_id = await _provider(tenant, project, per_user=True) + row = await _client(tenant, project, ap_id) + _a, s1 = await mcp_mod._auth_for(row, tenant, project, {"end_user_id": "u1"}) + _b, s2 = await mcp_mod._auth_for(row, tenant, project, {"end_user_id": "u2"}) + _c, s3 = await mcp_mod._auth_for(row, tenant, project, {"end_user_id": "u1"}) + assert s1 and s2 and s1 != s2, "two end users must not share one authenticated MCP session" + assert s1 == s3, "the same end user must reuse their own pooled session" + + +async def test_missing_provider_degrades_to_no_auth_rather_than_failing(): + tenant, project = "t_ma_m", "p_ma_m" + row = await _client(tenant, project, "does-not-exist") + auth, suffix = await mcp_mod._auth_for(row, tenant, project, {}) + assert auth is None and suffix == "" + + +async def test_invalidate_client_drops_every_per_user_variant(): + mcp_mod._CLIENT_CACHE.clear() + mcp_mod._CLIENT_CACHE["cid"] = (0.0, object()) + mcp_mod._CLIENT_CACHE["cid::abc"] = (0.0, object()) + mcp_mod._CLIENT_CACHE["cid::def"] = (0.0, object()) + mcp_mod._CLIENT_CACHE["other"] = (0.0, object()) + mcp_mod.invalidate_client("cid") + assert set(mcp_mod._CLIENT_CACHE) == {"other"} + + +def test_auth_context_from_puts_end_user_last(): + """end_user_id is authoritative: a caller-injected run_context value must not shadow it, + or one user could resolve another's stored credential.""" + + class _Ctx: + run_context = {"end_user_id": "spoofed", "csrf": "x"} + end_user = {"id": "real-user", "email": "real@acme.com"} + + out = mcp_mod.auth_context_from(_Ctx()) + assert out["end_user_id"] == "real-user" + assert out["csrf"] == "x" diff --git a/apps/api/tests/test_projects.py b/apps/api/tests/test_projects.py index d990b2e..cdb9b0c 100644 --- a/apps/api/tests/test_projects.py +++ b/apps/api/tests/test_projects.py @@ -61,13 +61,13 @@ async def test_project_counts_are_scoped_to_project(): counts = await ProjectService.counts(session, tenant_id, proj.id) assert counts == { "workflows": 2, "agents": 1, "tools": 3 + len(BUILTIN_DEFAULTS), - "components": 1, "knowledge": 1, "auth": 1, "handoffs": 1, + "components": 1, "knowledge": 1, "auth": 1, "handoffs": 1, "connectors": 0, } # A fresh project is all zeros except the auto-provisioned platform built-ins (never None). empty = await ProjectService.create(session, tenant_id, name="Empty", slug="empty") assert await ProjectService.counts(session, tenant_id, empty.id) == { "workflows": 0, "agents": 0, "tools": len(BUILTIN_DEFAULTS), "components": 0, "knowledge": 0, "auth": 0, - "handoffs": 0, + "handoffs": 0, "connectors": 0, } diff --git a/apps/api/tests/test_triggers.py b/apps/api/tests/test_triggers.py index 6ee0a5d..b1f4e97 100644 --- a/apps/api/tests/test_triggers.py +++ b/apps/api/tests/test_triggers.py @@ -2,16 +2,35 @@ from __future__ import annotations +from contextlib import aclosing from datetime import datetime, timedelta +import httpx from langgraph.checkpoint.memory import InMemorySaver from forge.db.base import SessionLocal -from forge.models import Trigger, Workflow +from forge.main import create_app +from forge.models import Trigger, User, Workflow +from forge.security import create_access_token from forge.services.dispatch import dispatch_trigger from forge.services.runs import RunService from forge.services.triggers import TriggerService + +async def _client_for(user_id: str, tenant: str, role: str) -> httpx.AsyncClient: + c = httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app()), base_url="http://test") + c.headers["Authorization"] = f"Bearer {create_access_token(user_id=user_id, tenant_id=tenant, role=role)}" + return c + + +async def _make_user_role(tenant: str, email: str, role: str) -> str: + async with SessionLocal() as s: + u = User(tenant_id=tenant, email=email, role=role, status="active") + s.add(u) + await s.commit() + await s.refresh(u) + return u.id + _WEBHOOK_WF = { "id": "wf_hook", "version": 1, "state": {"messages": {"type": "list[message]", "reducer": "add_messages"}}, @@ -72,3 +91,274 @@ async def test_dispatch_webhook_runs_workflow(): rs = RunService(checkpointer=InMemorySaver()) result = await dispatch_trigger(rs, trigger, {"text": "ping"}) assert result.get("answer") == "Done." and result.get("status") == "done" + + +# --- who an unattended run acts as ---------------------------------------------------------- +# +# Nobody is signed in when a webhook or a schedule fires, but every catalog connector is +# per-user. Without an identity on the trigger, a scheduled workflow has no token to resolve and +# dies at its first tool call - so the trigger carries the person who set it up. + +async def test_sync_stamps_the_editor_as_the_run_as_identity(): + async with SessionLocal() as s: + wf = Workflow(tenant_id="t_owner", project_id="p_owner", name="Owned", + executable=_WEBHOOK_WF, status="active") + s.add(wf) + await s.commit() + await s.refresh(wf) + [trig] = await TriggerService.sync_from_workflow(s, wf, owner="user-alice") + assert trig.run_as_user_id == "user-alice" + + +async def test_a_colleague_editing_the_workflow_does_not_take_over_the_accounts(): + """The automation runs on whoever's Gmail is connected behind it. Repointing that at the last + person to touch the canvas would change what the workflow can see without anyone deciding to.""" + async with SessionLocal() as s: + wf = Workflow(tenant_id="t_owner2", project_id="p_owner2", name="Owned2", + executable=_WEBHOOK_WF, status="active") + s.add(wf) + await s.commit() + await s.refresh(wf) + await TriggerService.sync_from_workflow(s, wf, owner="user-alice") + [trig] = await TriggerService.sync_from_workflow(s, wf, owner="user-bob") + assert trig.run_as_user_id == "user-alice" + + +async def test_a_trigger_predating_the_column_adopts_the_next_editor(): + """Triggers that already exist have no owner. The next save claims them, so an upgrade + doesn't leave every existing automation permanently unable to use a connector.""" + async with SessionLocal() as s: + wf = Workflow(tenant_id="t_owner3", project_id="p_owner3", name="Owned3", + executable=_WEBHOOK_WF, status="active") + s.add(wf) + await s.commit() + await s.refresh(wf) + [before] = await TriggerService.sync_from_workflow(s, wf) # pre-upgrade shape + assert before.run_as_user_id is None + [after] = await TriggerService.sync_from_workflow(s, wf, owner="user-carol") + assert after.run_as_user_id == "user-carol" + + +async def test_dispatch_binds_the_run_to_the_trigger_owner(): + """The payoff: the run carries an identity, so a per-user auth provider resolves THAT + person's connected credential instead of failing with "no acting user".""" + from forge.models import Run, Thread + + async with SessionLocal() as s: + wf = Workflow(tenant_id="t_ru", project_id="p_ru", name="RunAs", + executable=_WEBHOOK_WF, status="active") + s.add(wf) + await s.commit() + await s.refresh(wf) + [trigger] = await TriggerService.sync_from_workflow(s, wf, owner="user-dana") + + rs = RunService(checkpointer=InMemorySaver()) + result = await dispatch_trigger(rs, trigger, {"text": "ping"}) + assert result.get("status") == "done" + + async with SessionLocal() as s: + run = await s.get(Run, result["run_id"]) + thread = await s.get(Thread, run.thread_id) + # The dim AuthResolver.bundle_secret_name hashes for a per-user provider. + assert (thread.meta or {}).get("end_user") == {"id": "user-dana"} + assert thread.user_external_id == "user-dana" + + +async def test_run_as_can_be_claimed_by_yourself_but_assigned_only_by_an_editor(): + """Claiming narrows a trigger to accounts YOU connected, so anyone may. Pointing it at + someone else means their credentials get used by a workflow they may never have touched.""" + import uuid + + app = create_app() + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c: + reg = await c.post("/v1/auth/register", + json={"email": f"t{uuid.uuid4().hex[:10]}@example.com", "password": "supersecret1"}) + c.headers["Authorization"] = f"Bearer {reg.json()['access_token']}" + me = (await c.get("/v1/auth/me")).json() + tenant, owner_id = me["tenant_id"], me["id"] + pid = (await c.post("/v1/projects", json={"name": "T", "slug": f"trig-{uuid.uuid4().hex[:8]}"})).json()["id"] + + async with SessionLocal() as s: + wf = Workflow(tenant_id=tenant, project_id=pid, name="Hooked", executable=_WEBHOOK_WF, status="active") + s.add(wf) + await s.commit() + await s.refresh(wf) + [trig] = await TriggerService.sync_from_workflow(s, wf) + tid = trig.id + other = User(tenant_id=tenant, email=f"o{uuid.uuid4().hex[:8]}@example.com", role="viewer", status="active") + s.add(other) + await s.commit() + await s.refresh(other) + other_id = other.id + + rows = (await c.get(f"/v1/projects/{pid}/triggers")).json() + assert rows[0]["run_as_user_id"] is None + assert rows[0]["run_as_email"] is None + + # An editor may hand it to someone else. + r = await c.put(f"/v1/projects/{pid}/triggers/{tid}/run-as", json={"user_id": other_id}) + assert r.status_code == 200, r.text + assert r.json()["run_as_user_id"] == other_id + # ...but not to a user who isn't in the workspace. + assert (await c.put(f"/v1/projects/{pid}/triggers/{tid}/run-as", + json={"user_id": "nope"})).status_code == 404 + + # That viewer can claim it back for themselves without any elevated role. + token = create_access_token(user_id=other_id, tenant_id=tenant, role="viewer") + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app()), base_url="http://test") as c2: + c2.headers["Authorization"] = f"Bearer {token}" + assert (await c2.put(f"/v1/projects/{pid}/triggers/{tid}/run-as", json={})).status_code == 200 + row = (await c2.get(f"/v1/projects/{pid}/triggers")).json()[0] + assert row["run_as_is_me"] is True and row["run_as_email"] + # ...but may not push it onto a colleague. + assert (await c2.put(f"/v1/projects/{pid}/triggers/{tid}/run-as", + json={"user_id": owner_id})).status_code == 403 + + +# --- whose automation is it ----------------------------------------------------------------- +# +# Independent of run_as: `scope` says who the trigger BELONGS to. A salesperson's lead-chaser is +# theirs; a platform team's prod monitor is the project's. + +def test_new_triggers_default_to_the_project_for_admins_and_to_the_person_otherwise(): + from forge.services.triggers import default_scope_for + + assert default_scope_for("owner") == "project" + assert default_scope_for("admin") == "project" + assert default_scope_for("editor") == "user" + assert default_scope_for("viewer") == "user" + # An unknown/absent role must not accidentally publish something to the whole team. + assert default_scope_for(None) == "user" + + +async def test_scope_is_set_on_creation_and_never_flipped_by_an_edit(): + """Editing a workflow must not move a team automation into someone's private list, nor + publish someone's private one to everybody.""" + async with SessionLocal() as s: + wf = Workflow(tenant_id="t_scope", project_id="p_scope", name="Scoped", + executable=_WEBHOOK_WF, status="active") + s.add(wf) + await s.commit() + await s.refresh(wf) + [trig] = await TriggerService.sync_from_workflow(s, wf, owner="user-alice", scope="user") + assert trig.scope == "user" + # An admin later edits the same workflow - the trigger stays Alice's. + [again] = await TriggerService.sync_from_workflow(s, wf, owner="user-admin", scope="project") + assert again.scope == "user" + + +async def test_pre_existing_triggers_stay_visible_to_everyone(): + """The upgrade must not make a team's existing automations vanish from their screen, so a + trigger synced with no scope is the project's.""" + async with SessionLocal() as s: + wf = Workflow(tenant_id="t_scope2", project_id="p_scope2", name="Legacy", + executable=_WEBHOOK_WF, status="active") + s.add(wf) + await s.commit() + await s.refresh(wf) + [trig] = await TriggerService.sync_from_workflow(s, wf) + assert trig.scope == "project" + + +async def test_personal_triggers_are_listed_only_for_their_owner_and_project_admins(): + import uuid + + app = create_app() + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c: + reg = await c.post("/v1/auth/register", + json={"email": f"t{uuid.uuid4().hex[:10]}@example.com", "password": "supersecret1"}) + c.headers["Authorization"] = f"Bearer {reg.json()['access_token']}" + me = (await c.get("/v1/auth/me")).json() + tenant, admin_id = me["tenant_id"], me["id"] + pid = (await c.post("/v1/projects", json={"name": "T", "slug": f"sc-{uuid.uuid4().hex[:8]}"})).json()["id"] + + async with SessionLocal() as s: + alice = User(tenant_id=tenant, email=f"alice{uuid.uuid4().hex[:6]}@example.com", role="editor", status="active") + bob = User(tenant_id=tenant, email=f"bob{uuid.uuid4().hex[:6]}@example.com", role="editor", status="active") + s.add_all([alice, bob]) + await s.commit() + await s.refresh(alice) + await s.refresh(bob) + alice_id, bob_id = alice.id, bob.id + + wf = Workflow(tenant_id=tenant, project_id=pid, name="Alice's chaser", + executable=_WEBHOOK_WF, status="active") + s.add(wf) + await s.commit() + await s.refresh(wf) + [trig] = await TriggerService.sync_from_workflow(s, wf, owner=alice_id, scope="user") + tid = trig.id + + # The registering user is the workspace owner -> sees it, flagged as not theirs. + rows = (await c.get(f"/v1/projects/{pid}/triggers")).json() + assert [r["id"] for r in rows] == [tid] + assert rows[0]["scope"] == "user" and rows[0]["visible_via_oversight"] is True + + # Alice sees her own. + ca = await _client_for(alice_id, tenant, "editor") + async with aclosing(ca): + rows = (await ca.get(f"/v1/projects/{pid}/triggers")).json() + assert [r["id"] for r in rows] == [tid] + assert rows[0]["run_as_is_me"] is True and rows[0]["visible_via_oversight"] is False + + # Bob, an equal-ranking colleague, does not. + cb = await _client_for(bob_id, tenant, "editor") + async with aclosing(cb): + assert (await cb.get(f"/v1/projects/{pid}/triggers")).json() == [] + + # ...and cannot make someone else's trigger personal-to-nobody or grab it. + r = await cb.put(f"/v1/projects/{pid}/triggers/{tid}/scope", json={"scope": "project"}) + assert r.status_code == 200, "an editor may share a trigger with the project" + # Now that it's the project's, Bob can see it. + assert len((await cb.get(f"/v1/projects/{pid}/triggers")).json()) == 1 + + # Alice can take it back to personal - it is her account doing the work. + ca2 = await _client_for(alice_id, tenant, "editor") + async with aclosing(ca2): + assert (await ca2.put(f"/v1/projects/{pid}/triggers/{tid}/scope", json={"scope": "user"})).status_code == 200 + cb2 = await _client_for(bob_id, tenant, "editor") + async with aclosing(cb2): + assert (await cb2.get(f"/v1/projects/{pid}/triggers")).json() == [] + assert admin_id # (the owner above) + + +async def test_a_viewer_cannot_publish_a_trigger_to_the_whole_project(): + """Sharing makes colleagues see and depend on it, so it is an editor decision.""" + import uuid + + app = create_app() + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c: + reg = await c.post("/v1/auth/register", + json={"email": f"t{uuid.uuid4().hex[:10]}@example.com", "password": "supersecret1"}) + c.headers["Authorization"] = f"Bearer {reg.json()['access_token']}" + tenant = (await c.get("/v1/auth/me")).json()["tenant_id"] + pid = (await c.post("/v1/projects", json={"name": "T", "slug": f"sc-{uuid.uuid4().hex[:8]}"})).json()["id"] + async with SessionLocal() as s: + wf = Workflow(tenant_id=tenant, project_id=pid, name="V", executable=_WEBHOOK_WF, status="active") + s.add(wf) + await s.commit() + await s.refresh(wf) + [trig] = await TriggerService.sync_from_workflow(s, wf, scope="user") + tid = trig.id + + viewer_id = await _make_user_role(tenant, f"v{uuid.uuid4().hex[:6]}@example.com", "viewer") + cv = await _client_for(viewer_id, tenant, "viewer") + async with aclosing(cv): + assert (await cv.put(f"/v1/projects/{pid}/triggers/{tid}/scope", + json={"scope": "project"})).status_code == 403 + # An unknown scope is rejected outright rather than silently stored. + assert (await cv.put(f"/v1/projects/{pid}/triggers/{tid}/scope", + json={"scope": "everyone"})).status_code == 422 + + +async def test_an_unowned_trigger_still_runs_for_workflows_that_need_no_identity(): + """Not every workflow touches a per-user connector. One that doesn't must keep firing on a + trigger nobody has claimed - the identity is a requirement of the tools, not of dispatch.""" + wf = await _make_wf("t_noowner", "p_noowner") + async with SessionLocal() as s: + trig = (await s.execute(Trigger.__table__.select().where(Trigger.workflow_id == wf.id))).fetchone() + trigger = await TriggerService.by_key(s, trig.key) + assert trigger.run_as_user_id is None + rs = RunService(checkpointer=InMemorySaver()) + result = await dispatch_trigger(rs, trigger, {"text": "ping"}) + assert result.get("status") == "done" diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index b36131f..fbe4d00 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -16,6 +16,7 @@ import { AuthProvidersScreen } from "@/components/screens/auth"; import { SettingsScreen } from "@/components/screens/settings"; import { ConnectScreen } from "@/components/screens/deploy"; import { McpClientsScreen } from "@/components/screens/mcp"; +import { ConnectorsScreen } from "@/components/screens/connectors"; import { ChannelsScreen, TriggersScreen, DatasetsScreen, HandoffScreen } from "@/components/screens/platform"; import { Icon } from "@/components/icons"; import { api, Agent, ComponentT, DashboardStats, Project, Tool, Workflow } from "@/lib/api"; @@ -29,7 +30,7 @@ const SCREEN_LABEL: Record = { overview: "Overview", workflows: "Workflows", "workflow-canvas": "Support Router", agents: "Agents", "agent-config": "billing_agent", tools: "Tools", "tool-builder": "Tool", components: "Components", "component-builder": "Component", auth: "Auth Providers", knowledge: "Knowledge", playground: "Playground", traces: "Traces", - connect: "Connect", mcp: "External MCP", settings: "Settings", + connect: "Connect", mcp: "External MCP", connectors: "Connectors", settings: "Settings", channels: "Channels", triggers: "Triggers", datasets: "Evaluations", handoff: "Agent inbox", embed: "Embed", }; const PARENT: Record = { @@ -44,6 +45,8 @@ function App() { const [projects, setProjects] = useState([]); const [loaded, setLoaded] = useState(false); const [selTool, setSelTool] = useState(null); + // Tool set to focus when the Tools screen opens (set by "Open in Tools" on a connector). + const [focusToolSet, setFocusToolSet] = useState(null); const [selWorkflow, setSelWorkflow] = useState(null); const [selAgent, setSelAgent] = useState(null); const [selComponent, setSelComponent] = useState(null); @@ -81,7 +84,12 @@ function App() { const project = view.project ? cards.find((c) => c.id === view.project) || projects.find((p) => p.id === view.project) : null; const go = (v: View) => setView(v); - const navScreen = (screen: string) => setView((v) => ({ ...v, name: "project", screen })); + const navScreen = (screen: string) => { + // The connector -> tool-set focus is a one-shot hand-off; clear it on any OTHER navigation + // so returning to Tools later shows the whole list rather than a stale filter. + if (screen !== "tools") setFocusToolSet(null); + setView((v) => ({ ...v, name: "project", screen })); + }; async function deleteProject(projectToDelete: { id: string; name: string }, opts?: { skipConfirm?: boolean }) { if (!opts?.skipConfirm && !window.confirm(`Delete project "${projectToDelete.name}"?\n\nThis removes its workflows, agents, tools, auth providers, knowledge, secrets, runs, and traces. This cannot be undone.`)) return; await api.deleteProject(projectToDelete.id); @@ -192,11 +200,14 @@ function App() { case "workflow-canvas": return navScreen("workflows")} onRun={() => navScreen("playground")} onRegisterFlush={(fn) => { canvasFlushRef.current = fn; }} />; case "agents": return { setSelAgent(a); navScreen("agent-config"); }} />; case "agent-config": return navScreen("agents")} />; - case "tools": return { setSelTool(t); navScreen("tool-builder"); }} />; + case "tools": return { setSelTool(t); navScreen("tool-builder"); }} />; case "tool-builder": return navScreen("tools")} />; case "components": return { setSelComponent(c); navScreen("component-builder"); }} />; case "component-builder": return navScreen("components")} />; case "auth": return ; + // "Open in Tools" from a connector jumps to the Tools screen focused on its tool set, + // so the actions a connector just created are one click from the canvas. + case "connectors": return { setFocusToolSet(setId); navScreen("tools"); }} />; case "mcp": return ; case "knowledge": return ; case "channels": return ; diff --git a/apps/web/components/canvas/AgentConfig.tsx b/apps/web/components/canvas/AgentConfig.tsx index 1651722..70a55d3 100644 --- a/apps/web/components/canvas/AgentConfig.tsx +++ b/apps/web/components/canvas/AgentConfig.tsx @@ -18,6 +18,9 @@ export function AgentConfig({ config, onChange, tools = [], toolSets = [], agent const selectedTools: string[] = config.tools || []; const selectedComponents: string[] = config.components || []; const mwCount = (config.middleware || []).filter((m: MW) => m.enabled !== false).length; + // Servers a person registered by hand. A connector's server is excluded because its actions + // are already grantable above as that connector's tool set. + const rawMcpServers = mcpServers.filter((m) => !m.connector_slug); const toggleTool = (id: string) => set({ tools: selectedTools.includes(id) ? selectedTools.filter((t) => t !== id) : [...selectedTools, id] }); @@ -130,6 +133,9 @@ export function AgentConfig({ config, onChange, tools = [], toolSets = [], agent
toggleOpen(s.id)}>
+ {/* A connector's set carries its icon, so Slack/Gmail/… are recognizable + at a glance rather than reading as anonymous folders. */} + {s.icon && } {s.name} {sel}/{members.length}
@@ -174,7 +180,7 @@ export function AgentConfig({ config, onChange, tools = [], toolSets = [], agent
)} {toolSets.length === 0 && ungrouped.length === 0 && ( -
No tools yet — create some on the Tools screen.
+
No tools yet — install a connector (Slack, Gmail, …) on the Connectors screen, or build one on the Tools screen.
)} @@ -260,10 +266,14 @@ export function AgentConfig({ config, onChange, tools = [], toolSets = [], agent - {mcpServers.length > 0 && ( -
+ {/* Connector-owned servers are deliberately absent: their actions are already offered + above as a tool set, and listing the same integration twice - once as "Notion" the set + and again as "Notion" the server - makes one connection look like two choices that do + the same thing. Only hand-registered servers need a grant here. */} + {rawMcpServers.length > 0 && ( +
- {mcpServers.map((m) => { + {rawMcpServers.map((m) => { const on = (config.mcp_servers || []).includes(m.id); return ( + MCP servers · advanced +
+
+ + ); + } + + return ( +
+
+ + {/* header */} +
+
Connectors
+
+ {searching || q ? ( + setQ(e.target.value)} + onBlur={() => { if (!q) setSearching(false); }} + onKeyDown={(e) => { if (e.key === "Escape") { setQ(""); setSearching(false); } }} /> + ) : ( + + )} +
+ + {addOpen && ( + <> +
setAddOpen(false)} /> +
+ { setAddOpen(false); setCustomOpen(true); }} /> + { setAddOpen(false); setView("mcp"); }} /> +
+ + )} +
+
+
+
+ Connect your own accounts. Each sign-in is personal to you — your colleagues connect theirs. +
+ + {err &&
{err}
} + {note &&
{note}
} + + {/* popular for */} + {popular.length > 0 && ( +
+
+ Popular for + +
+
+ {popular.map((r) => ( +
+ + connect(r.slug)} onOpen={() => setSel(r.slug)} /> +
+ ))} +
+
+ )} + + {/* filter */} +
+ {([["all", "All"], ["connected", "Connected"], ["not", "Not connected"]] as [Filter, string][]).map(([k, label]) => ( + + ))} +
+ + {/* table */} +
+
+
Connector
+
Type
+
Status
+
+ {visible.length === 0 ? ( + + ) : visible.map((r) => ( +
+ +
{r.type === "MCP" ? "MCP server" : "REST API"}
+
+ connect(r.slug)} onOpen={() => setSel(r.slug)} /> +
+
+ ))} +
+ +
+ Connecting creates an auth provider, a tool set, and one tool per action — so its actions show up in{" "} + Tools and can be dropped straight onto a workflow canvas or granted to an agent. The + tools are the project's; the account behind them is yours. + {unavailable > 0 && ( + <> {unavailable} connector{unavailable === 1 ? " isn't" : "s aren't"} set up on this deployment yet — + open one to see which app to register. + )} +
+
+ + {selected && ( + connect(selected.slug)} + onClose={() => setSel(null)} onChanged={reload} + onOpenToolSet={onOpenToolSet} + /> + )} + {customOpen && setCustomOpen(false)} onInstalled={() => { setCustomOpen(false); reload(); }} />} +
+ ); +} + +function MenuItem({ icon, title, sub, onClick }: { icon: string; title: string; sub: string; onClick: () => void }) { + return ( + + ); +} + +/** The right-hand cell. For anything the deployment can actually sign you into, this IS the + * flow - one click straight to the vendor, no intermediate screen. */ +function StatusAction({ row, busy, onConnect, onOpen }: { + row: Row; busy: boolean; onConnect: () => void; onOpen: () => void; +}) { + if (isConnected(row)) { + return ; + } + if (!isAvailable(row)) { + return ; + } + // A pasted connector with a key/token credential has nothing to sign in to - its detail panel + // is where you manage it. + if (row.install && row.auth?.kind !== "oauth2_authorization_code") { + return ; + } + return ( + + ); +} + +/* ---------------------------------------------------------------------------------------- */ + +function ConnectorDetail({ project, slug, row, busy, onConnect, onClose, onChanged, onOpenToolSet }: { + project: any; slug: string; row: Row; busy: boolean; onConnect: () => void; + onClose: () => void; onChanged: () => void; onOpenToolSet?: (setId: string) => void; +}) { + const [detail, setDetail] = useState(null); + const [values, setValues] = useState>({}); + const [work, setWork] = useState(null); + const [err, setErr] = useState(null); + const [note, setNote] = useState(null); + const install = row.install; + + useEffect(() => { + if (row.publisher === "custom") { setDetail(row); return; } + api.connectorDetail(project.id, slug).then(setDetail).catch(() => setDetail(row)); + }, [project.id, slug, row]); + + const spec = detail || row; + const managed = spec.managed !== false; + const available = isAvailable({ ...spec, install }); + const connected = isConnected(row); + const oauth = spec.auth?.kind === "oauth2_authorization_code"; + + const run = async (label: string, fn: () => Promise) => { + setWork(label); setErr(null); setNote(null); + try { await fn(); } catch (e: any) { setErr(e?.message || "Something went wrong"); } finally { setWork(null); } + }; + + return ( + +
+
+ +
+
{spec.summary}
+
+ {spec.type === "MCP" ? "MCP server" : "REST API"} + {connected + ? Connected as you + : install && Not connected} +
+
+
+ + {install?.status_detail &&
{install.status_detail}
} + {err &&
{err}
} + {note &&
{note}
} + + {/* what it can do */} + {spec.actions && spec.actions.length > 0 && ( +
+ {spec.actions.length} actions +
+ {spec.actions.map((a) => ( +
+ {a.method} + + {a.name} + {a.description} + +
+ ))} +
+
+ )} + {spec.type === "MCP" && !install && ( +
+ Actions are discovered from the vendor's MCP server the first time you connect, so the + list stays current as they ship new ones. +
+ )} + {spec.auth?.scopes?.length > 0 && available && ( + +
{spec.auth.scopes.map((s) => {s.replace(/^https:\/\/www\.googleapis\.com\/auth\//, "")})}
+
+ )} + + {!available ? ( + + ) : !install || (oauth && !connected) ? ( + <> + {managed && ( +
+ You'll sign in to {vendor(spec.name)} in a new window and approve access. The connection is + yours alone — nobody else in this project can act as you, and you can disconnect at any time. +
+ )} +
+ + {spec.docs_url && API docs } +
+ + ) : null} + + {install && ( + <> +
+ {install.tool_count} action{install.tool_count === 1 ? "" : "s"} + {install.auth_mode === "per_user" ? "Personal connection" : "Shared credential"} + {install.tool_set_id && onOpenToolSet && ( + + )} +
+
+ {connected && oauth && ( + + )} + {spec.type === "MCP" && ( + + )} + {connected && install.auth_kind !== "none" && ( + + )} + +
+ {/* Rotating a credential only applies to a pasted connector - a catalog one's app + lives in the deployment's environment, where its owner rotates it. */} + {install.source !== "catalog" && (spec.auth?.setup || []).some((f) => f.secret) && ( +
+ Update credentials +
+ {(spec.auth?.setup || []).filter((f) => f.secret).map((f) => ( + + setValues((v) => ({ ...v, [f.key]: e.target.value }))} /> + + ))} + +
+
+ )} + + )} +
+
+ ); +} + +/** The deployment hasn't registered this vendor. That is an operator's job, in the environment - + * so say precisely what to register and where it goes, rather than showing an end user a form + * for a client secret that shouldn't be theirs to hold. */ +function NotConfigured({ spec }: { spec: ConnectorCatalogEntry }) { + const keys = spec.missing_keys || []; + const envLine = `${spec.config_env_key || "FORGE_CONNECTOR_OAUTH_APPS"}={"${spec.credential_group}":{${keys.map((k) => `"${k}":"…"`).join(",")}}}`; + return ( +
+
Not set up on this Forge deployment yet
+
+ Whoever runs Forge registers one {vendor(spec.name)} OAuth app — with the redirect URI + below — and adds it to the environment. After a restart, everyone here connects their own + {" "}{vendor(spec.name)} account with a single click; nobody types a secret into Forge. +
+ + e.currentTarget.select()} /> + + +
+ {spec.setup_url && Register an app } + {spec.docs_url && API docs } +
+ {spec.auth?.setup_help &&
{spec.auth.setup_help}
} +
+ ); +} + +/** OAuth apps need the exact callback whitelisted; showing it removes the usual first failure. + * The value comes from the SERVER, because the redirect_uri the flow actually sends is built + * from the API's public base URL - not the console's origin the browser happens to be on. A + * guessed URL looks plausible and then fails with redirect_uri_mismatch. */ +function RedirectHint({ url }: { url?: string }) { + if (!url) return null; + return ( + +
+ + +
+
+ ); +} + +/* ---------------------------------------------------------------------------------------- */ + +function CustomConnectorModal({ project, onClose, onInstalled }: { project: any; onClose: () => void; onInstalled: () => void }) { + const [text, setText] = useState(""); + const [checked, setChecked] = useState<{ ok: boolean; error?: string; connector?: ConnectorCatalogEntry; hosts?: string[] } | null>(null); + const [values, setValues] = useState>({}); + const [perUser, setPerUser] = useState(false); + const [examples, setExamples] = useState([]); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + + // Bundled starting points for the services that can't be one-click (an API key or a bot token + // has to come from a person). Best-effort: a viewer without editor rights just doesn't see them. + useEffect(() => { api.connectorExamples(project.id).then(setExamples).catch(() => setExamples([])); }, [project.id]); + + const parsed = useMemo(() => { try { return JSON.parse(text); } catch { return null; } }, [text]); + + const validate = async () => { + setErr(null); + if (!parsed) { setChecked({ ok: false, error: "That isn't valid JSON." }); return; } + try { setChecked(await api.validateConnectorManifest(project.id, parsed)); } + catch (e: any) { setErr(e?.message || "Could not validate"); } + }; + + const install = async () => { + setBusy(true); setErr(null); + try { + await api.installCustomConnector(project.id, { manifest: parsed, values, auth_mode: perUser ? "per_user" : "shared" }); + onInstalled(); + } catch (e: any) { setErr(e?.message || "Install failed"); } finally { setBusy(false); } + }; + + const setup = checked?.connector?.auth?.setup || []; + + return ( + +
+
+ Paste a forge.connector/1 manifest. It installs exactly like a catalog + entry — same validation, same rows — so a private connector pack can live in your own repo instead of a + fork of Forge. This is also where a credential you type yourself belongs: an API key, a bot token, or one + shared service account for the whole project. +
+ {examples.length > 0 && ( + + + + )} + +