Connections to other services
connectionsLet 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.
connectionsFree in your app, Apache-2.0Status 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.
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]import { connections } from "mainsdk/experimental/connections";
const res = await connections.fetch("github", "https://api.github.com/user", {
scope: "read:user", agent: "support-bot", user,
});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 callbackDrivers
9 drivers. Select one to see its line in main.yaml.
connections:
driver: googleChange this one line to swap providers; your calls stay the same.Google (Gmail integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-CONN-0020 | Checkmainsdk check, before deploy | An agent or step-up names a scope not under its provider's scopes, or a provider not under connections.providers |
MAIN-CONN-0021 | Checkmainsdk check, before deploy | A provider has no preset and no hosts, so no call could be sent |
MAIN-CONN-0001 | RuntimeError responses and logs | The provider isn't under connections.providers (500 at start for config, 400 for a call) |
MAIN-CONN-0002 | RuntimeError responses and logs | No active connection to this provider for this person or tenant (403); the fix names connections.connect |
MAIN-CONN-0003 | RuntimeError responses and logs | The connection wasn't granted the scope this call needs (403); the fix asks to connect again with it |
MAIN-CONN-0004 | RuntimeError responses and logs | The agent may not use this scope: it isn't under agents.<name>.connections.<provider> (403); nothing is sent |
MAIN-CONN-0005 | RuntimeError responses and logs | The URL's host isn't one of the provider's hosts, or isn't https; the token is not sent (403) |
MAIN-CONN-0006 | RuntimeError responses and logs | The callback was refused: state missing, unknown, expired, used or from another session, code verifier rejected, or issuer mismatch (400) |
MAIN-CONN-0007 | RuntimeError responses and logs | The connection expired or was revoked at the provider (invalid_grant), or its key was destroyed; connect again (401) |
MAIN-CONN-0008 | RuntimeError responses and logs | The provider couldn't be reached to refresh an expired token (503, retryable) |
MAIN-CONN-0009 | RuntimeError responses and logs | connect asked for a scope beyond providers.<name>.scopes (400); nothing is sent |
MAIN-CONN-0010 | RuntimeError responses and logs | See the spec |
MAIN-MCP-0007 | RuntimeError responses and logs | A remote server's protected resource metadata is missing or invalid, or its resource isn't the server's URL (502) |
MAIN-MCP-0008 | RuntimeError responses and logs | The remote server's authorization server offers no dynamic registration and no client-id is set for it (500) |
MAIN-MCP-0009 | RuntimeError responses and logs | A token sent to the app's MCP server was issued for another resource (401); it is never passed on |
MAIN-MCP-0010 | RuntimeError responses and logs | A 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.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| OAuth connect flow with one consent screen | Phase 1 | 0.3 | connections#flow |
| Sealed token store in the tenant's region | Phase 1 | 0.3 | connections#store |
| Fetch with a scope and host check | Phase 1 | 0.3 | connections#fetch |
| Refresh and rotation | Phase 1 | 0.3 | connections#refresh |
| List, manage and revoke | Phase 1 | 0.3 | connections#manage |
| Per-agent scopes | Phase 1 | 0.3 | connections#agent-scopes |
| Step-up approval for risky scopes | Phase 1 | 0.3 | connections#step-up |
| Audit of every use | Phase 1 | 0.3 | connections#audit |
| MCP client authorization | Phase 1 | 0.3 | connections#mcp-client-auth |
| MCP server authorization | Phase 1 | 0.3 | connections#mcp-server-auth |
| Erase a connection on request | Phase 1 | 0.3 | connections#erase |
| Checks for undeclared scopes and providers | Phase 1 | 0.3 | connections#checks |
Quick start
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]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
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]| Key | What it does |
|---|---|
providers.<name>.scopes | The most the app may ever ask for |
providers.<name>.hosts | The only hosts a token may be sent to, over https |
providers.<name>.step-up | Scopes whose use by an agent waits for a person's yes |
agents.<name>.connections | The 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
| Call | What 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
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
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
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
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
| Code | When | Fix |
|---|---|---|
MAIN-CONN-0002 | No active connection for this person or tenant (403) | Ask the person to connect |
MAIN-CONN-0003 | The connection lacks the scope this call needs (403) | Connect again with that scope |
MAIN-CONN-0004 | The agent may not use this scope (403). Nothing is sent | Add it under agents.<name>.connections |
MAIN-CONN-0005 | The 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-0007 | The grant expired or was revoked at the provider (401) | Connect again |
MAIN-CONN-0020 | An agent or step-up names a scope or provider that is not declared | Declare it under providers |
MAIN-CONN-0021 | A provider has no preset and no hosts | Set 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.
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 callbackSecurity 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
- Auth and sessions: who the person is.
- Keys: where the grant is sealed.
- Imports: pull from a provider on a schedule.
- Actions and MCP: the same grants reach remote MCP servers.
Was this page helpful?