MainSDK
Search⌘K
Interfaces

Identity and access

Connections to other services

connections

Let a person connect Gmail, GitHub or Slack once, so an app agent can act for them within the scopes they agreed to, with one place to take it back.

Building for 0.3ships as a experimental APIPhase 1 · Core, public betaSpec connectionsFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

connections lets an app agent act in another service for a person, within the scopes they agreed to. Reach for it to read their Gmail, post to Slack or open GitHub.

An agent may use only the scopes main.yaml gives it, risky scopes wait for a person's yes, and every use is logged. The app never sees a password.

Spec connections, for 0.3. Status: experimental, so it is imported from mainsdk/experimental/connections and may change before it is stable.

At a glance

connections:
  store: db
  callback: /api/connections/callback
  providers:
    github:
      preset: github
      client-id: Iv1.4f…
      client-secret: secret:GITHUB_OAUTH_SECRET
      scopes: [read:user, repo]
      step-up: [repo]
agents:
  support-bot:
    connections:
      github: [read:user]

Calls

  • connections.connect(req, provider, opts)
  • connections.callback(req)
  • connections.fetch(provider, url, init)
  • connections.list(opts?)
  • connections.revoke(id, opts?)
  • connections.describe(provider, scopes?)

Guarantees

Whatever the driver.

  • Tokens are sent only to the provider's hosts, over https
  • An agent uses only the scopes main.yaml gives it
  • Risky scopes wait for a person's yes
  • Every use of a connection is logged

Drivers

9 drivers. Select one to see its line in main.yaml.

main.yamlGoogle (Gmail
connections:
  driver: google

Change this one line to swap providers; your calls stay the same.Google (Gmail integration

Codes

CodeWhere it appearsMeaning
MAIN-CONN-0020Checkmainsdk check, before deployAn agent or step-up names a scope not under its provider's scopes, or a provider not under connections.providers
MAIN-CONN-0021Checkmainsdk check, before deployA provider has no preset and no hosts, so no call could be sent
MAIN-CONN-0001RuntimeError responses and logsThe provider isn't under connections.providers (500 at start for config, 400 for a call)
MAIN-CONN-0002RuntimeError responses and logsNo active connection to this provider for this person or tenant (403); the fix names connections.connect
MAIN-CONN-0003RuntimeError responses and logsThe connection wasn't granted the scope this call needs (403); the fix asks to connect again with it
MAIN-CONN-0004RuntimeError responses and logsThe agent may not use this scope: it isn't under agents.<name>.connections.<provider> (403); nothing is sent
MAIN-CONN-0005RuntimeError responses and logsThe URL's host isn't one of the provider's hosts, or isn't https; the token is not sent (403)
MAIN-CONN-0006RuntimeError responses and logsThe callback was refused: state missing, unknown, expired, used or from another session, code verifier rejected, or issuer mismatch (400)
MAIN-CONN-0007RuntimeError responses and logsThe connection expired or was revoked at the provider (invalid_grant), or its key was destroyed; connect again (401)
MAIN-CONN-0008RuntimeError responses and logsThe provider couldn't be reached to refresh an expired token (503, retryable)
MAIN-CONN-0009RuntimeError responses and logsconnect asked for a scope beyond providers.<name>.scopes (400); nothing is sent
MAIN-CONN-0010RuntimeError responses and logsSee the spec
MAIN-MCP-0007RuntimeError responses and logsA remote server's protected resource metadata is missing or invalid, or its resource isn't the server's URL (502)
MAIN-MCP-0008RuntimeError responses and logsThe remote server's authorization server offers no dynamic registration and no client-id is set for it (500)
MAIN-MCP-0009RuntimeError responses and logsA token sent to the app's MCP server was issued for another resource (401); it is never passed on
MAIN-MCP-0010RuntimeError responses and logsA token sent to the app's MCP server lacks the scope the tool needs (403, insufficient_scope)

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
OAuth connect flow with one consent screenPhase 10.3connections#flow
Sealed token store in the tenant's regionPhase 10.3connections#store
Fetch with a scope and host checkPhase 10.3connections#fetch
Refresh and rotationPhase 10.3connections#refresh
List, manage and revokePhase 10.3connections#manage
Per-agent scopesPhase 10.3connections#agent-scopes
Step-up approval for risky scopesPhase 10.3connections#step-up
Audit of every usePhase 10.3connections#audit
MCP client authorizationPhase 10.3connections#mcp-client-auth
MCP server authorizationPhase 10.3connections#mcp-server-auth
Erase a connection on requestPhase 10.3connections#erase
Checks for undeclared scopes and providersPhase 10.3connections#checks

Quick start

main.yaml
connections:
  providers:
    github:
      preset: github
      client-id: Iv1.4f…
      client-secret: secret:GITHUB_OAUTH_SECRET
      scopes: [read:user, repo]
      step-up: [repo]
agents:
  support-bot:
    connections:
      github: [read:user]
app/connect/route.ts
import { connections } from "mainsdk/experimental/connections";

export async function GET(req: Request) {
  return connections.connect(req, "github", { returnTo: "/settings/accounts" });
}

connect returns a redirect to the provider's consent screen, with PKCE (S256), a single-use state that is bound to the person's session and lives ten minutes, and the exact redirect_uri. Mount connections.callback(req) at the callback path; it checks the state, the session and the issuer (RFC 9207), swaps the code, seals the tokens and returns { connection, returnTo }.

Configuration

main.yaml
connections:
  store: db                     # db (the tenant's region) | memory (tests and local mode)
  callback: /api/connections/callback
  providers:
    gmail:
      preset: google            # google, github, slack, microsoft, notion, atlassian, airtable
      client-id: 1234.apps.googleusercontent.com
      client-secret: secret:GOOGLE_OAUTH_SECRET
      scopes: [https://www.googleapis.com/auth/gmail.readonly, https://www.googleapis.com/auth/gmail.send]
      hosts: [gmail.googleapis.com]
      step-up: [https://www.googleapis.com/auth/gmail.send]
    partner:                    # no preset: declare every endpoint
      authorize: https://id.partner.com/oauth/authorize
      token: https://id.partner.com/oauth/token
      revoke: https://id.partner.com/oauth/revoke
      issuer: https://id.partner.com
      client-id: abc
      scopes: [items:read]
      hosts: [api.partner.com]
agents:
  support-bot:
    connections:                # the only scopes this agent may use, per provider
      gmail: [https://www.googleapis.com/auth/gmail.readonly]
KeyWhat it does
providers.<name>.scopesThe most the app may ever ask for
providers.<name>.hostsThe only hosts a token may be sent to, over https
providers.<name>.step-upScopes whose use by an agent waits for a person's yes
agents.<name>.connectionsThe scopes each agent may use, per provider

Client secrets go in as secret: references. A provider with no preset must declare its authorize, token and hosts.

What you get

CallWhat it does
connections.connect(req, provider, opts)Redirects the person to the provider's consent screen, with PKCE and a single-use state
connections.callback(req)Checks the state, session and issuer, swaps the code, seals the tokens and returns { connection, returnTo }
connections.fetch(provider, url, init)Calls the provider as an agent with a required scope, only to the provider's hosts over https
connections.list(opts?)A page of connections, never with a token in it
connections.revoke(id, opts?)Tells the provider and deletes the tokens at once; a job retries when the provider is down
connections.describe(provider, scopes?)A line of text for each scope, to render your own consent screen

The providers with a preset are google, github, slack, microsoft, notion, atlassian and airtable; any other OAuth provider declares its endpoints. store: memory keeps connections in this process for tests and local mode.

Examples

Call the provider as an agent

app/agents/support.ts
import { connections } from "mainsdk/experimental/connections";

declare const user: { id: string };   // the signed-in person

const res = await connections.fetch("gmail", "https://gmail.googleapis.com/gmail/v1/users/me/messages", {
  scope: "https://www.googleapis.com/auth/gmail.readonly",
  agent: "support-bot",
  user,
});

scope is required and must be one the agent holds. If the scope is in step-up, the call returns Pending while the person decides.

Show the person what they are agreeing to

app/settings/accounts.ts
import { connections } from "mainsdk/experimental/connections";

const info = connections.describe("gmail");
// info.scopes: [{ scope, text, stepUp }], info.agents: [{ agent, scopes }]

Use describe to render your own consent screen. It returns a line of text for each scope.

List and revoke

ts
import { connections } from "mainsdk/experimental/connections";

const { items } = await connections.list({ limit: 20 });
await connections.revoke(items[0].id, { reason: "user removed the account" });

Revoking tells the provider (RFC 7009) and deletes the tokens at once. If the provider cannot be reached, a connections.revoke job holds a separately sealed copy of the token and retries until the provider accepts it; nothing else can read it. No agent can use the grant after revoke.

Reach a remote MCP server with OAuth

main.yaml
mcp:
  servers:
    - { name: docs, url: https://mcp.example.com/mcp, auth: oauth }
connections:
  mcp:
    dynamic-registration: true   # register with the server's authorization server (RFC 7591)

The first call gets a 401 with resource_metadata. The client reads the protected resource metadata (RFC 9728) and the authorization server's metadata (RFC 8414), registers once, and answers with MAIN-CONN-0002 until the person connects with connections.connect(req, "mcp:docs", { returnTo }). That redirect carries resource=<server URL> (RFC 8707) and PKCE. Metadata about another resource fails with MAIN-MCP-0007; an authorization server with no registration endpoint and no client-id fails with MAIN-MCP-0008; an authorization server host that network does not allow fails with MAIN-NET-0002.

Let your own MCP server take OAuth tokens

mcpServer({ actions, oauth: { resource, issuer } }) serves its protected resource metadata at /.well-known/oauth-protected-resource, refuses a token issued for another resource (MAIN-MCP-0009, 401) and a token without the tool's scope (MAIN-MCP-0010, 403 with error="insufficient_scope"), and refuses a token in the query string. The app is never an authorization server: issuer is your auth driver's issuer.

Errors and checks

CodeWhenFix
MAIN-CONN-0002No active connection for this person or tenant (403)Ask the person to connect
MAIN-CONN-0003The connection lacks the scope this call needs (403)Connect again with that scope
MAIN-CONN-0004The agent may not use this scope (403). Nothing is sentAdd it under agents.<name>.connections
MAIN-CONN-0005The URL's host is not one of the provider's hosts, or is not https. The token is not sent (403)Call a listed host
MAIN-CONN-0007The grant expired or was revoked at the provider (401)Connect again
MAIN-CONN-0020An agent or step-up names a scope or provider that is not declaredDeclare it under providers
MAIN-CONN-0021A provider has no preset and no hostsSet a preset or list its hosts

The other runtime codes (MAIN-CONN-0001, 0006, 0008, 0009, 0010, and MAIN-MCP-0007 to 0010) are listed in check codes.

Swapping drivers

A connection is a grant to one provider, so there is no driver to swap. To move to another provider, add it under providers, have the person connect, and point the agent's connections at the new scopes.

Testing

createTestApp with connections: { store: memory } keeps connections in the process. createFakeOAuth from @mainsdk/testing stands in for the provider: it checks PKCE and the exact redirect_uri, rotates refresh tokens, and can answer invalid_grant or go down. Nothing leaves the process. See the testing kit.

app/connect/route.test.ts
import { createFakeOAuth, createTestApp } from "@mainsdk/testing";

const fake = createFakeOAuth({
  authorize: "https://github.com/login/oauth/authorize",
  token: "https://github.com/login/oauth/access_token",
  apiHosts: ["api.github.com"],
});
const app = createTestApp({ yaml: "schema: 1\nconnections:\n  store: memory\n  providers:\n    github:\n      preset: github\n      client-id: cid\n      scopes: [read:user]\n" });
// connect, play the person's consent with fake.consent(redirect), then call the callback

Security and privacy

  • Tokens are sealed with the person's key (the tenant's for a tenant connection) and never returned to the app, a listing, a log or an error. Destroying the person's key makes the connection unusable (MAIN-CONN-0007).
  • A token goes only to the provider's hosts, over https.
  • Every use is written to the audit log with the agent, the scope and the person.

FAQ

Does the person need to connect for each agent? No. Connect once; each agent is limited by its own connections list.

Can a tenant own a connection instead of a person? Yes, with for: "tenant", which needs the connections.manage permission.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet