Connectors Module
MCP connector instances, credentials, and provider registry
The Connectors module manages per-org MCP integration instances and the global connector provider catalog. Source: packages/server/src/modules/connectors/ and packages/server/src/modules/connector-providers/.
Connectors REST Endpoints
All endpoints are prefixed with /api/v1/connectors and require ClerkAuthGuard.
| Method | Path | Description |
|---|---|---|
| GET | /connectors | List org's connector instances |
| POST | /connectors | Create a connector instance |
| PATCH | /connectors/:instanceId | Rename a connector's account label |
| GET | /connectors/:instanceId/credentials | Get credentials for an instance |
| POST | /connectors/:instanceId/credentials | Update credentials |
| DELETE | /connectors/:instanceId | Delete a connector instance |
Connector Providers REST Endpoints
| Method | Path | Guards | Description |
|---|---|---|---|
| GET | /connector-providers | None | List enabled providers (public) |
| PUT | /connector-providers | ClerkAuthGuard | Create or update a provider (admin) |
| DELETE | /connector-providers/:serverId | ClerkAuthGuard | Delete a provider (admin) |
Connectors Service (services/connectors.service.ts)
list(orgId, userId)
Returns only the connector instances owned by the requesting user. userId is
required — every connector belongs to exactly one user (COS-739), so there is no
org-wide view and no way to list another member's connections.
There used to be an org-shared mode where userId was null and one credential
served the whole org. It was removed: one member disconnecting took the
connector away from everyone, and members could read the admin's tokens.
Hosting override logic:
REMOTE_OAUTHandNONEconnectors:hostingis set toLOCALin the response (the desktop manages these locally).- All others:
hostingisCLOUD(server manages credentials).
create(orgId, userId, dto)
Creates a new MCPConfig record. Input:
| Field | Required | Description |
|---|---|---|
serverId | Yes | Provider ID (e.g. "notion") |
instanceId | Yes | Client-generated unique ID |
label | Yes | Human-readable name |
hosting | No | CLOUD / LOCAL |
transport | No | STDIO / SSE / HTTP |
config | No | Non-sensitive config JSON |
credentials | No | Initial credentials JSON |
userId is always stored on the record, for every provider — the caller becomes
the owner. perUserAuth no longer affects scoping; it only describes where the
provider's tokens live.
Uniqueness constraint: (orgId, userId, instanceId).
serverId must resolve to a real provider — seeded (ConnectorProvider) or one
of this org's own custom servers (CustomConnectorProvider). A serverId that
matches neither is refused with a 400 (Unknown connector provider "...")
rather than creating a connector row for a provider that can never actually
connect (QA follow-up on COS-739).
The desktop client proves the connector actually works before this endpoint is
ever called. ConnectorManager.addInstance opens a real MCP client connection
to the endpoint and closes it again — on the API_KEY branch with the supplied
key (QA follow-up on COS-739), and on the NONE branch with no credentials
(COS-979). Either way a connector that cannot serve MCP is refused at connect
time rather than being stored, marked CONNECTED, and only proving broken the
first time the chat engine reaches for its tools.
Because this is a real MCP initialize handshake rather than a reachability
ping, an ordinary web page answering 200 fails it. That is the point: the
server-side validation in custom-provider-validation.ts can only check what a
URL looks like (https, public host, no embedded credentials), which any
arbitrary address satisfies.
This is client-side by necessity — the server only ever receives credentials it
is handed, and has no way to dial an arbitrary custom MCP endpoint itself. See
probeMcpConnection in packages/desktop/src/main/connector-manager.ts.
REMOTE_OAUTH needs no separate probe: runRemoteOAuthFlow already performs
its own Client.connect() against the same endpoint, so a URL that is not an
MCP server fails there. STDIO connectors are exempt too — they run a local
child process and have no endpoint to dial (isRemoteProbeable).
getCredentials(orgId, instanceId, userId?)
Fetches the credentials JSON for a connector. For per-user connectors, userId must match the record's user.
updateCredentials(orgId, instanceId, credentials, userId)
Replaces the credentials JSON and updates status to CONNECTED and lastUsedAt to now.
rename(orgId, instanceId, label, userId)
Updates only the account label on the caller's own connector — the text that
distinguishes two instances of the same provider in the Integrations list.
Scoped through the same owner-only lookup as every other write (COS-739 QA
follow-up: there was previously no way to fix a typo in a connector's label
without disconnecting and reconnecting it). label is validated with the same
app-wide name bound as create's label field.
Connector Providers Service (services/connector-providers.service.ts)
listPublic()
Returns all providers where enabled: true, with sensitive fields stripped:
clientSecretis not included.authParamswith secrets are excluded.
Returned fields: serverId, displayName, description, iconUrl, category, authMode, perUserAuth, requiresAdminConsent, mcpTransport.
getOAuthConfig(serverId)
Internal method (not exposed via REST). Returns the full provider record including clientId, clientSecret, authUrl, tokenUrl, scopes. Used by MCPOAuthService.
getMCPConfig(serverId)
Returns the connection config for starting an MCP session:
{
command?: string; // For STDIO transport
args?: string[];
url?: string; // For HTTP/SSE transport
transport: 'stdio' | 'sse' | 'http';
}MCPConfig Schema
| Field | Type | Description |
|---|---|---|
id | string | Primary key |
orgId | string | Owning organisation |
userId | string | Owning user (required) |
serverId | string | Provider slug |
instanceId | string | Unique instance ID |
label | string | Human-readable name |
hosting | CLOUD | LOCAL | Where the MCP process runs |
transport | STDIO | SSE | HTTP | Connection transport |
config | JSON? | Non-sensitive config |
credentials | JSON? | Encrypted OAuth tokens or API key |
status | string | CONNECTED | DISCONNECTED | CONNECTING | ERROR |
lastError | string? | Last error message |
lastUsedAt | DateTime? | Last usage |
MCP OAuth Module
Server-mediated OAuth for integrations like Notion, Google Drive, and ClickUp. Source: packages/server/src/modules/mcp-oauth/.
OAuth Endpoints
| Method | Path | Guards | Description |
|---|---|---|---|
| POST | /mcp-oauth/start | ClerkAuthGuard | Start OAuth flow → get authUrl |
| GET | /mcp-oauth/callback | None | OAuth provider callback |
| POST | /mcp-oauth/refresh | None | Refresh an expired token |
| GET | /mcp-oauth/poll | None | Poll for credentials after callback |
OAuth Flow
Client Server OAuth Provider
│ │ │
├─POST /start───▶│ │
│◀─── authUrl ───┤ │
│ │ │
│ (user opens browser) │
│ │◀─ GET /callback ─┤
│ ├─ exchange code ──▶│
│ │◀─── tokens ───────┤
│ │ (store credentials)
│ │ │
├─GET /poll ────▶│ │
│◀─ credentials ─┤ │POST /mcp-oauth/start: Client sends{ serverId, instanceId, state }. Server looks up provider'sauthUrl+clientId+scopes, constructs the full URL, and returns it.- Client opens URL in browser. User grants access. Provider redirects to
GET /mcp-oauth/callback?code=...&state=.... - Server exchanges the code for tokens using the provider's
tokenUrl. - Credentials are stored temporarily (keyed by
state) pending the client's poll. GET /mcp-oauth/poll?state=...: Client polls until{ ready: true, credentials: {...} }.- Client calls
POST /connectors/:id/credentialsto persist the credentials.
MCPOAuthService
| Method | Description |
|---|---|
startFlow({ serverId, instanceId, state, orgId }) | Build and return auth URL |
handleCallback(state, code) | Exchange code, store credentials temporarily |
refreshToken(serverId, refreshToken) | Refresh using stored client secret |
pollCredentials(state) | Return { ready, credentials? } |