---
title: "Connections to other services"
description: "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."
section: "interfaces"
status: "Building for 0.3 · ships as a experimental API"
spec: "connections"
canonical: "https://mainsdk.dev/docs/interfaces/connections"
updated: "2026-10-28"
---

# Connections to other services

> 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.

Status: Building for 0.3 · ships as a experimental API · Canonical: https://mainsdk.dev/docs/interfaces/connections · 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

- Status: Building for 0.3 · ships as a experimental API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/connections.md
- Category: Identity and access

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?)`

In main.yaml:

```yaml
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]
```

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

Pick one in main.yaml with `connections.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Google (Gmail | `google` |  |  |
| GitHub | `github` |  |  |
| Slack | `slack` |  |  |
| Microsoft 365 | `microsoft` |  |  |
| Notion | `notion` |  |  |
| Atlassian | `atlassian` |  |  |
| Airtable | `airtable` |  |  |
| Any OAuth provider (declare every endpoint) | `partner` |  |  |
| Local (tests and local mode | `local` |  |  |

## Codes

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

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| OAuth connect flow with one consent screen | 1 | 0.3 | `connections#flow` |
| Sealed token store in the tenant's region | 1 | 0.3 | `connections#store` |
| Fetch with a scope and host check | 1 | 0.3 | `connections#fetch` |
| Refresh and rotation | 1 | 0.3 | `connections#refresh` |
| List, manage and revoke | 1 | 0.3 | `connections#manage` |
| Per-agent scopes | 1 | 0.3 | `connections#agent-scopes` |
| Step-up approval for risky scopes | 1 | 0.3 | `connections#step-up` |
| Audit of every use | 1 | 0.3 | `connections#audit` |
| MCP client authorization | 1 | 0.3 | `connections#mcp-client-auth` |
| MCP server authorization | 1 | 0.3 | `connections#mcp-server-auth` |
| Erase a connection on request | 1 | 0.3 | `connections#erase` |
| Checks for undeclared scopes and providers | 1 | 0.3 | `connections#checks` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="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]
```

```ts title="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

```yaml title="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]
```

| 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

```ts title="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

```ts title="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

```yaml title="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

| 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](https://mainsdk.dev/docs/reference/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](https://mainsdk.dev/docs/interfaces/testing).

```ts title="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

- [Auth and sessions](https://mainsdk.dev/docs/interfaces/auth): who the person is.
- [Keys](https://mainsdk.dev/docs/interfaces/keys): where the grant is sealed.
- [Imports](https://mainsdk.dev/docs/interfaces/imports): pull from a provider on a schedule.
- [Actions and MCP](https://mainsdk.dev/docs/concepts/actions-and-mcp): the same grants reach remote MCP servers.
