Skip to content

MCP connectors API

The MCP connectors API manages connectors that bridge the gateway to external Model Context Protocol servers. All endpoints require an authenticated admin session and a tenant context.

Connectors are tenant-scoped. A connector may optionally be bound to a single gateway via the gateway_id field.

Request body shape. Endpoints that take a body (create, update, call-proxy, credential store) expect a JSON object. A body that is null, a bare scalar (42, true, "x"), or absent (empty or whitespace) is treated as an empty object; a JSON array is passed through unchanged. A malformed / unparseable body is a different case: it is rejected at the trust boundary with 400 malformed_body ("request body is not valid JSON"), never collapsed to an empty object. Create, the call-proxy, and credential-store then reject the empty body with 400 (a missing required field such as name/server_url); a partial update treats it as a no-op and returns 200 with the unchanged connector. In no case does a non-object body cause a 500.


Listing connectors

GET /admin/v1/mcp

Optional query parameter: gateway_id to filter by a specific gateway.

The response omits the auth_value field for every entry. Rows are owner-filtered to the caller (the tenant's shared/admin-provisioned connectors plus the caller's own private ones), never another member's private connector. Returns 403 when there is no tenant context and 401 when the caller has no user identity (fail closed — a missing user id never falls through to an unfiltered tenant-wide list).

Each row carries a server-computed connected boolean — usable by this caller. A scope = "tenant" row is always connected: true (the connector carries its own credential) and has no credential_status. A scope = "user" row is decorated by the same rule as GET /admin/v1/mcp/mine (connected + credential_status, table below): a no-auth connector (auth_type = "none") without a stored credential is connected: true / credential_status: "ready", a required-auth connector without a credential is connected: false / "none". The Chat sends every connected connector's tools to the model, so a no-auth personal connector reaches the model as created. A storage fault while reading the caller's credential returns 503 rather than silently reporting the connector as disconnected.


Listing my personal connectors

GET /admin/v1/mcp/mine

Member-accessible (requires only a tenant context and an authenticated user; not tenant_admin-gated). Returns only the caller's tenant connectors with scope = "user", each decorated with a connected boolean reflecting whether this caller can use the connector (a stored per-user credential, or a no-auth connector that needs none). The auth_value field is never returned. The response is an array, empty when no user-scoped connectors exist. Returns 403 when there is no tenant context, 401 when unauthenticated.

Each row also carries a credential_status reflecting the caller's stored credential, so the UI can offer the right action:

credential_status Meaning UI action
connected a per-user credential is stored (and valid) Disconnect offered
expired the stored credential is past its expires_at and has no refresh token, or the provider has since rejected it (see below) Reconnect + Disconnect offered
ready auth_type = "none" and no credential is stored — usable as created none
none a required-auth connector with no credential yet Connect offered

💡 Note (a rejected token reads expired): the status is no longer derived from expires_at alone. When a stored per-user credential is rejected by the provider at call time (a 401/403 on the tools/list/tools/call handshake), the gateway records the rejection and this row flips to expired — even if its expires_at is still in the future and it has a refresh token — so "Reconnect needed" is shown instead of a permanent, false "Connected". The mark is cleared automatically on the next successful call (self-heal, so a transient provider 401 does not pin the row) and on every reconnect/refresh. The rejection check takes precedence over the refresh-token short-circuit.

Because a stored per-user credential is transmitted to the MCP server on presence alone (see Storing my credential), a connector that has a stored credential is reported connected — offering Disconnect — even when its auth_type is none. ready is reserved for a none-typed connector with genuinely nothing to revoke; it never masks a credential that is still egressing.

💡 Note: The connector CRUD routes manage tenant-scoped connectors. Creating (POST /mcp) requires the MCP_AUTHOR permission — for the built-in roles that is member, ki_manager, tenant_admin, or admin (a viewer, demouser, or finance user gets 403), and a tenant custom role granting MCP_AUTHOR is admitted regardless of its base role. Reading, editing, and deleting a connector (GET /mcp/<ID>, PATCH, DELETE) require being the connector's owner or a tenant_admin/admin — not a blanket tenant_admin; an admin-provisioned tenant-wide connector (no owner) is admin-only. The self-service routes — GET /mcp/mine and the /mcp/<ID>/credential lifecycle below — are member-accessible and act only on the calling user's own credential rows.


Listing the connector directory

GET /admin/v1/mcp/catalog

Returns the connector directory the create form pre-fills from: { "entries": [ … ], "oauth_redirect_uri": "…" }. Each entry describes a known connector type (its display name, endpoint template, and auth shape); the list carries no secrets. oauth_redirect_uri is the deployment's public OAuth callback URL to register in an identity-provider app (null when not configured on this deployment). Accessible to any caller holding MCP_AUTHOR (the connector-authoring permission); a viewer, demouser, or finance user receives 403, and a caller with no tenant receives 403.

Active-set filtering + ordering. Entries are returned active-first, then alphabetical by slug, and each carries a boolean active flag. For a non-platform-admin caller the list is filtered to the active set — the active_mcp_connectors setting, defaulting to ["gmail","slack"] when no platform admin has written it — so only the operator-enabled connectors are self-serve discoverable. A platform admin (role admin) sees every entry, each flagged active/inactive, so the platform-admin Feature Flags card can render and toggle the full universe. This is a discoverability control, not an authorization boundary: a connector row that already exists keeps working, and POST /mcp still accepts any valid catalog_slug (or none). Adding/hiding a shipped connector needs no code change — only a settings edit.

Internal kinds are excluded from the directory. A connector directory entry has a kind: mcp (the default — a JSON-RPC tool server), data_source (a content source the connector-sync engine pulls from — see connector sync), or governed_query (a first-party governed database-query connector answered through the runner; see the governed_query fence below). The governed_query kind is omitted from this listing — it is created programmatically by the governed-query enrollment flow, never picked from the directory — so it never appears in either the platform-admin universe or the active set.

Creating a connector

POST /admin/v1/mcp

Field Type Required
name string yes
server_url string yes
auth_type none | bearer | header no (defaults to none)
auth_value string required when auth_type is bearer or header
scope tenant | user no (defaults to tenant)
gateway_id string no
catalog_slug string no (must name a real directory entry)
entra_tenant_id string (GUID) required only for a tenant-scoped catalog entry (see below)
environment prod | sandbox no (Salesforce templated entry only; defaults to prod)
server_api_name string no (Salesforce templated entry only; defaults to sobject-reads)

A user-scope connector must not carry an auth_value — its credential is held per user — so supplying one returns 400. For a tenant-scope connector, auth_type none must not carry an auth_value (returns 400), and bearer or header requires a non-empty auth_value.

URL rules (each rejected with 400 and the message shown here; the connector is not created):

Input Rejected Message
server_url not an http:// / https:// URL (for example ftp://…), empty, or over 500 bytes server_url must be an http(s):// URL (max 500 bytes)
server_url (members only) a private, internal, loopback or link-local address, or a name that resolves to one — a member's connector may only reach the public internet; an admin may register an internal address (for example a data warehouse) server_url must be a public address (private/internal addresses aren't allowed)
oauth_authorize_url / oauth_token_url not an https:// URL (an http:// URL is refused), containing whitespace or control characters, or missing while any other oauth_* field is present (any oauth_* key means "OAuth intended", so both URLs are then required) oauth_authorize_url must be an https:// URL / oauth_token_url must be an https:// URL
oauth_authorize_url / oauth_token_url (members only) a private/internal address oauth_authorize_url must be a public address (private/internal addresses aren't allowed) / oauth_token_url must be a public address (private/internal addresses aren't allowed)

The response status is 201 Created. The auth_value is not echoed back.

Templated connectors (server-side URL substitution)

Some directory connectors reach an endpoint whose URL varies per organization. For these the catalog ships a URL template containing one or more {placeholder} tokens, and the gateway substitutes rule-validated values into the trusted template server-side when you create the connector. The client's own server_url is always ignored and overwritten for a templated entry — the resolved host, scheme and path come only from the boot-constant template plus values that pass a strict allowlist, so a crafted value can never redirect the connection to another host. auth_type is forced to bearer and scope to user (these are per-user OAuth connectors).

Every placeholder is validated fail-closed; an unknown or malformed token never ships literally. The registered placeholders are:

  • {tenant_id} — Microsoft Entra Directory (tenant) GUID (used by Microsoft SharePoint, Microsoft OneDrive, Microsoft Graph). Supplied in the entra_tenant_id field.
  • Accepted shape: a canonical GUID — 32 hex digits in 8-4-4-4-12 form (e.g. 00000000-0000-0000-0000-000000000000). Nothing else.
  • Rejected (400 invalid tenant_id): the *.onmicrosoft.com domain form, values containing /, :, @, ?, #, %, \, whitespace or control characters, a GUID with a trailing path/query, an over- or under-length value, or a non-string (null/number/array). A missing value on such an entry returns 400 entra_tenant_id is required for this connector.
  • {environment} — Salesforce org environment. Supplied in the environment field; defaults to prod.
  • Accepted shape: exactly prod or sandbox (a closed enum, mapped server-side to the trusted path segment — your value never reaches the URL directly).
  • Rejected (400 invalid environment): any other value, including different casing (PROD), production, an already-mapped path (platform, sandbox/platform), the raw {environment}, or a value with surrounding whitespace/newlines.
  • {server_api_name} — Salesforce activated-server API name (e.g. sobject-reads, sobject-all). Supplied in the server_api_name field; defaults to sobject-reads.
  • Accepted shape: 1–64 characters of A–Z a–z 0–9 _ - only, substituted verbatim as the final path leaf.
  • Rejected (400 invalid server_api_name): an empty value, over 64 characters, or anything containing ., /, :, @, ?, #, %, \, whitespace, control characters (so it can neither add a path segment, traverse with .., nor change host/scheme), or a non-string.

OAuth URLs. How the OAuth authorize/token URLs are handled depends on whether the entry also templates them:

  • Tenant-templated OAuth (Microsoft Entra — SharePoint/OneDrive/Graph): the two OAuth URLs are derived from the same tenant GUID and overwritten server-side; a client-supplied oauth_authorize_url/oauth_token_url is ignored. You still supply your enterprise-app oauth_client_id (required, else 400), oauth_client_secret and oauth_scopes.
  • Manual OAuth (Salesforce): only the server_url is templated. The OAuth authorize/token URLs are your org's My Domain endpoints — you supply them (along with oauth_client_id/oauth_client_secret), and they are still SSRF-validated (a private/internal address is rejected with 400) and remain editable via PATCH. The server_url stays managed and immutable on PATCH.

Salesforce Hosted MCP

Salesforce rides the official Salesforce Hosted MCP Servers (GA 2026-04). The catalog templates the endpoint https://api.salesforce.com/platform/mcp/v1/{environment}/{server_api_name} — production resolves to .../v1/platform/<server> and sandbox to .../v1/sandbox/platform/<server>. The default preset is the read-only sObject reference server on production (sobject-reads), the safest starting point; override with environment/server_api_name for a sandbox org or a different activated server. oauth_scopes defaults to refresh_token offline_access mcp_api (the mandatory Hosted-MCP scope set) and is applied server-side when you omit it. Salesforce uses a manual External Client App (no Dynamic Client Registration): before connecting, an admin must be on Enterprise Edition or above, activate servers under Setup → MCP Servers, and create the External Client App (with PKCE required) to obtain the consumer key/secret and the My Domain OAuth URLs. Every MCP call runs as the authenticated Salesforce user, so that user's CRUD/field-level/sharing permissions apply.


Getting a connector

GET /admin/v1/mcp/<ID>

Access is not manage. This detail route returns the connector's stored auth_value (a live third-party credential) only to a caller who may manage that connector — its owner, or a tenant_admin/admin. An admin-provisioned tenant-wide connector has no owner, so it is admin-only: a plain member, ki_manager, or viewer who does not own it receives 403 and the auth_value is never in that response. (A member reading their own scope = "user" connector gets 200, but such a row carries no shared auth_value — user scope rejects one at create.) A manager reads the value verbatim so the edit form can round-trip it (a PATCH that omits or nulls auth_value preserves the stored secret — COALESCE). oauth_client_secret is never returned to anyone. The member-accessible list routes above (GET /mcp, GET /mcp/mine) never carry auth_value or oauth_client_secret.


Updating a connector

PATCH /admin/v1/mcp/<ID>

Patchable fields: name, server_url, auth_type, auth_value, scope, tool_policy, and the OAuth fields (oauth_authorize_url, oauth_token_url, oauth_client_id, oauth_client_secret, oauth_scopes). The gateway_id binding is set at create time and is not patchable. The same scope/auth_value rules as create apply to the connector's resulting state, and so do the URL rules above (a non-http(s):// server_url, a member's private server_url, a non-https:// or — for a member — private OAuth URL each return 400 with the same message).

For a tenant-scoped (Microsoft 365) connector, the server_url, oauth_authorize_url and oauth_token_url are managed (derived from your Entra tenant ID) and immutable: a PATCH that changes any of them returns 400 … is managed for this connector and cannot be changed. Re-sending the stored value unchanged is allowed, so a rename or a scope/tool-policy edit still succeeds.

💡 Note (per-tool permissions on per-user connectors): setting a tool_policy needs the connector's tool list, which the editor loads with a tools/list handshake. For a scope = "user" connector (Slack/Gmail) this handshake runs on the caller's own stored per-user credential, so the editor's tool-enumeration control is now available when editing a saved per-user connector (previously it was hidden, so their tools could never be loaded and every remote tool — including write actions like Gmail "Create draft email" — stayed callable under "Allow by default"). Connect your own credential first, then load the tools and set per-tool exceptions; enforcement remains server-side at call time.

Switching a user-scope connector to auth_type = "none" additionally clears the caller's stored per-user credential. Because a stored credential is transmitted on presence alone, leaving it in place would keep egressing a personal/OAuth token on every tool call even though the connector is now declared no-auth; the switch therefore revokes it. This happens only on a real transition (the connector was not already none) and affects only the calling user's own credential — a rename or other edit of an already-none connector never touches a credential.


Deleting a connector

DELETE /admin/v1/mcp/<ID>

The response is 204 No Content.


Calling a connector (JSON-RPC proxy)

POST /admin/v1/mcp/<ID>/call

Proxies a JSON-RPC 2.0 request body to the configured server_url using the connector's authentication. The caller must have access to the connector's tenant.

Request body — verbatim JSON-RPC 2.0:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {}
}

The response is the verbatim JSON-RPC 2.0 response from the upstream server.

The upstream is untrusted: its reply is decoded fail-closed. A response is accepted only when it is a real JSON-RPC response — a JSON object carrying a result or error. Anything else — a bare scalar (42/true/"x"/null), a top-level JSON array, or a non-response object (e.g. a bare notification) — is rejected as empty or undecodable rather than acted on. This holds identically for a plain application/json reply and a Streamable-HTTP text/event-stream reply: over SSE, interleaved progress/log notifications are skipped and only the response event is returned; a stream that carries no response event (notification-only) fails closed, never returning a notification in its place.

The response body is read-bounded to guard the worker against a hostile or buggy server: the gateway reads at most 32 MiB of the upstream reply (enough for a rich tools/list — the 500-tool × ≤16 KB-schema budget above is ≈10–12 MiB — with generous headroom). A reply that exceeds that cap (by an honest Content-Length or by streaming past it) is aborted at the read and treated as empty or undecodable — it is never buffered whole into memory. When the connector is invoked from the server-side inference tool-loop (referencing it by id in tools, not this proxy), the whole tools/call is additionally bounded by a 90-second total deadline — enforced across both the response-header read and the response-body read — so a slow-drip server that dribbles either the status line / headers or the body one byte at a time (resetting the per-receive socket timeout on each byte) cannot pin the turn. The discovery tools/list probe and this admin /call proxy carry their own total deadlines (30 s and 60 s) on the same basis.

💡 Note: This proxy is for administrative inspection (e.g. tools/list to see which tools a connector exposes). To use a connector from chat or the inference API you do not call it here — you reference it by id (tools:[{"type":"mcp","connector_id":"…"}]) and the gateway resolves and runs its tools server-side. See MCP connectors from the API.

Connector kinds and the governed_query fence

Every connector resolves to a directory kind (from its immutable, create-validated catalog_slug), and that kind decides whether it may ride this JSON-RPC HTTP call path at all. The check is applied once, at the single resolve-and-call chokepoint shared by this admin proxy, agent discovery, and the chat/inference tool-loop — so no path can be reached that another path fences off.

kind Rides the JSON-RPC server_url HTTP path?
mcp (default) Yes — it is a JSON-RPC tool server.
data_source No — a content source pulled by the connector-sync engine, not a tool server.
governed_query No — a co-signed, single-use compiled artifact answered through the runner against a customer's own database, never a JSON-RPC tool server.

A data_source or governed_query connector invoked on any tool path is refused, fail-closed (returned as not found, exactly as an unknown connector) and never dials the upstream server_url. The classification keys strictly off a string catalog_slug: an absent slug means an ordinary mcp connector (it proceeds), and a malformed (non-string) slug is not silently treated as a governed query — it can neither masquerade as one nor bypass into a different treatment, so garbage cannot defeat the fence. Governed-query dispatch to the runner (and its distinct failure modes) is delivered separately; until it lands, a governed_query connector is refused everywhere it could be invoked.


Using connectors from the inference API

API callers do not need the /call proxy or a hand-built tool list to use a connector in a chat completion: reference it by id and the gateway resolves its tools and runs the tool loop server-side —

"tools": [{ "type": "mcp", "connector_id": "<connector id>" }]

The full request shape, authorization rules, rejection table, and operational notes are documented in Inference API — MCP connectors from the API.

Per-tool shape narrowing at discovery

The connector's tools/list result is untrusted third-party input: every tool definition is narrowed fail-closed before it can reach a model or a provider wire body. Per tool:

  • name — required. Must be a non-empty string of valid UTF-8, at most 200 bytes. Anything else (missing, wrong type, oversized, invalid bytes) drops the tool. Names colliding with built-in gateway tools or the reserved agent__ prefix are likewise dropped.
  • description — optional. A non-string value is dropped (the tool is kept). Strings are scrubbed to valid UTF-8 (invalid bytes become U+FFFD) and capped at 4000 bytes on a character boundary.
  • inputSchema — must be a JSON object. Its top-level type is stamped "object" when absent; a schema whose type is anything else, a schema that is an array or scalar, a schema that encodes to more than 16 KB, or a schema whose encoded form contains invalid UTF-8 at any depth is replaced by the minimal empty object-schema {"type":"object","properties":{}} (the tool stays callable, without declared parameters). One level deeper, a non-object properties is replaced by {} and a required that is not an array of strings is removed. Deeper per-property sub-schemas are not otherwise type-validated — the schema is advisory to the model, and tool execution is authorized by the connector, not by the declared schema.
  • At most 500 tools per connector are accepted; the excess is ignored (a per-agent tool allow-list filters this capped set).

Narrowing is logged (aggregate warning per connector/request) and surfaced in the request trace. The same rules are applied to entries of the gateway-internal x-aig-mcp-tools body carrier — see Inference API — per-tool shape narrowing.

The model's context additionally carries a sanitized system notice naming each attached connector and its tools, so the model knows the connectors are available — see Connector provenance notice.


PII protection on outbound tool-call arguments

When a chat model calls an MCP tool, the arguments the model generates egress to the connector's MCP server. On a PII-active gateway (one carrying a pii_protector guardrail) — or under a PII mandate from any source: a tenant with pii_masking_enforced, a pii_mandatory project, or a forced agent invoke (an unresolvable project tier blocks tool egress outright, earlier) — those arguments are inspected before they are sent. A tool call is blocked (refused, never sent) when an argument carries a protected structured identifier: a checksum-valid German ID (Steuer-ID, USt-IdNr, SV-Nr, KV-Nr, Personalausweis), an IBAN, a credit-card number, an email address, a JWT, an API key, a [MYRA-REDACT-…] token echo, or a configured custom_pii keyword. Free-text names and places are not blocked — the tool needs the real value to function. A blocked call returns a model-facing message so the model can retry without the protected data. An argument object that cannot be fully inspected (unparseable or excessively nested) is treated as unsafe and blocked (fail-closed).

Escape hatch — trusted_subprocessor. A connector column trusted_subprocessor (boolean, default false) marks a connector as a vetted EU/DPA subprocessor. A trusted connector skips the argument block, so a tool that legitimately requires full-identifier egress can run. Set it deliberately, per connector; the default (false) is fail-closed (block).

Set the flag with PATCH /admin/v1/mcp/<ID>/trusted (body { "trusted_subprocessor": true | false }). The route requires the GOVERNANCE_MANAGE permission, and a non-boolean value is rejected with 400. The change is always captured by four-eyes approval for a tenant admin — the response is 202 with a pending_approval status and an approval_id until a second administrator approves it — while a platform admin applies it directly.

This block applies to the inference tool-loop path only. The JSON-RPC /call proxy above carries a user's own arguments (a manual, user-initiated call, not model-generated egress) and is out of scope for this control.


Connectors in a project that forbids external egress

A Tier-1 (local_only) project blocks externally-egressing tools. For MCP connectors this is per-connector, not all-or-nothing: a connector that stays inside the estate — one owned by the same tenant whose server_url is a private/internal address — is kept, gets its normal tools/list call, and its tools run. Only references that would egress outside the estate (a different tenant, or a public server_url) are dropped. Nothing leaves the estate either way.

What happens to the dropped (external) references depends on the surface:

Surface Signal Behaviour
Interactive chat sends x-aig-mcp-best-effort: 1 The external references are dropped and the turn proceeds — on local tools plus any kept in-estate connectors. A tools_skipped notice lists connectors_policy.
Raw inference API header absent (or any value other than 1) Any in-estate connectors are kept and the turn proceeds; the request is refused 403 forbidden only when every referenced connector is external.

So a 403 now means all referenced connectors are external — a request mixing an in-estate connector with external ones keeps the in-estate one and proceeds (the external refs are dropped, with the chat notice above). The split exists so an auto-attached convenience connector cannot silently kill a chat turn, while a raw caller whose connectors are all external still gets an explicit refusal.

A run marked no-egress by its profile (a webhook-triggered agent run) stays fail-closed regardless: every connector reference is dropped — there is no in-estate exception on that path — and the best-effort opt-in is honoured only when the block comes from the project tier, never the run profile.

Previously the chat surface was refused too. On a gateway with a PII detector that refusal arrived as an empty 200 stream (the scanning_pii event had already committed the response), so the user saw no answer and an "access denied" banner on every turn in such a project.


Getting my credential status

GET /admin/v1/mcp/<ID>/credential

Member-accessible (requires tenant + authenticated user; not tenant_admin). Reports whether the calling user holds a credential for the connector; the stored secret value itself is never returned.

Field Type Notes
exists boolean true when the caller has a stored credential
expires_at unix seconds | null null when no expiry is set or no credential exists

Returns 403 when there is no tenant context, 401 when unauthenticated, 404 when the connector is not found in the caller's tenant, and 503 on a transient credential-store read fault (fail-closed, so a blip never reads as "no credential").


Storing my credential

PUT /admin/v1/mcp/<ID>/credential

Member-accessible (requires tenant + authenticated user; not tenant_admin). Stores or replaces the calling user's per-user credential for a connector. Only valid for connectors with scope = "user".

Once stored, a per-user credential is always transmitted to the MCP server as a Bearer token, regardless of the connector's declared auth_type — the presence of the credential is what makes the call authenticated. A connector left at auth_type = "none" therefore still sends a personal token if one has been stored; auth_type on a user-scope connector only governs whether a missing credential is fatal (bearer → the call is rejected with a reconnect prompt, none → the call proceeds unauthenticated).

Field Type Required Notes
auth_value string yes must be a non-empty string
expires_at unix seconds no when set, must be a whole future timestamp within 100 years (a non-integer, past, or milliseconds-magnitude value is rejected)
refresh_token string no stored when supplied as a non-empty string

The credential is verified against the provider before it is stored. After the shape checks above, the gateway performs the same tools/list handshake it uses everywhere else, authenticated with the pasted token (the token is passed as a validation candidate — nothing is written until it succeeds). This closes the old defect where any text was accepted and the connector read "Connected" forever while a later tools/list failed with a generic 502. Outcomes:

  • Verified → the credential is stored; response { "ok": true }.
  • Rejected by the provider (a 401/403 on the handshake, or the connector otherwise reports the credential unusable) → 424 with { "error": <message>, "code": "connector_credential_required", "connector_id", "connector_name" }. Nothing is stored — the row does not read "Connected". This is the product's own connector-credential code, never a 502.
  • Unreachable / unverifiable (the connector did not answer — network error, protocol error, or the discovery deadline was exceeded) → 502 with { "error": <message>, "code": "connector_upstream" }. Nothing is stored (fail-closed: an unverifiable credential must not read "Connected"); this is a connectivity problem, distinct from a bad credential — retry when the connector is reachable.

Because the validation reuses the shared call chokepoint, it inherits its SSRF pinning and tool-policy guards, and a token pasted here that the provider rejects can never leave a working credential behind, nor mutate any existing stored credential (the candidate is never persisted).

Other returns: 400 when the connector is not scope = "user", when auth_value is missing or empty, or when expires_at is not a whole future unix-seconds value within 100 years; 404 when the connector is not found in the caller's tenant; 403 when there is no tenant context; 401 when unauthenticated; 500 on an unexpected credential store failure.

Note (OAuth-obtained credentials): a credential minted by the OAuth connect flow derives its expires_at from the provider's expires_in, which does not pass through this route's validator. To keep a misconfigured or hostile token endpoint from returning an expiry that would overflow the storage column and fail the connection, such a provider-derived expiry is clamped to a BIGINT-safe far-future bound at storage time. The OAuth callback therefore never 500s on a pathological token response.


Starting a per-user OAuth connect

GET /admin/v1/mcp/<ID>/oauth/start

Begins the per-user "Sign in with " round-trip for an OAuth connector. The browser navigates to this URL (it is not called as JSON), and the gateway responds with a 302 redirect to the provider's authorization page; the PKCE verifier is held server-side under an opaque state nonce — stored in the database (single-use, DB-backed so an active-active start/callback across sites still completes) — and the public callback completes the flow and stores the caller's per-user credential.

The route is available only to a caller holding MCP_AUTHOR (the connector-authoring permission — the built-in member/ki_manager/tenant_admin/admin roles or a custom role granting it; a viewer, demouser, or finance user receives 403) and only for a scope = "user" connector — a tenant-scoped connector routes on its shared credential and rejects this with 400. Returns 404 when the connector is not found in the caller's tenant or is not visible to the caller, and 503 when the deployment has no OAuth callback URL configured.


Deleting my credential

DELETE /admin/v1/mcp/<ID>/credential

Member-accessible (requires tenant + authenticated user; not tenant_admin). Removes the calling user's per-user credential for the connector. The response is { "ok": true }. Returns 403 when there is no tenant context, 401 when unauthenticated, 404 when the connector is not found in the caller's tenant, 500 on a credential delete failure.