> ## Documentation Index
> Fetch the complete documentation index at: https://docs.formal.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# MCP Gateway

> Route remote MCP servers through a Connector so any MCP client signs in with Formal

export const G = ({term, anchor, children}) => {
  const href = anchor ? `/docs/glossary/index#${anchor}` : `/docs/glossary/index`;
  return <a href={href} className="glossary-link" style={{
    textDecoration: "underline",
    textDecorationLine: "underline",
    textDecorationColor: "#6b7280",
    textDecorationThickness: "1px",
    textUnderlineOffset: "2px",
    color: "inherit",
    transition: "text-decoration-color 0.2s ease",
    borderBottom: "none"
  }} onMouseEnter={e => e.target.style.textDecorationColor = "#fff"} onMouseLeave={e => e.target.style.textDecorationColor = "#6b7280"}>
  {children || term}
</a>;
};

## Overview

With the MCP gateway, MCP clients reach remote MCP servers through a <G anchor="connector">Connector</G> instead of connecting to them directly.

<Tip>
  The [Set up the MCP gateway](https://app.formal.ai/recipes/mcp-gateway)
  recipe walks you through the listener, MCP servers, and network rule. See
  [Recipes](/docs/guides/getting-started/recipes).
</Tip>

<Note>
  The gateway works with remote MCP servers that clients reach over HTTPS. It doesn't cover local servers that clients start as a subprocess over stdio.
</Note>

You add servers such as Linear or Notion to the [Catalog](https://app.formal.ai/catalog), and each one gets its own address on the Connector:

```text theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
https://<server-name>.<connector-hostname>/<mcp-path>
```

MCP clients connect to that address and sign in with Formal. The Connector adds the upstream credential, enforces <G anchor="policy">policies</G>, and logs every tool call.

Authentication happens on two sides, and you configure each one separately:

| Side | Between | Options |
| - | - | - |
| **Upstream** | Connector and MCP server | Linked accounts (OAuth), shared credentials, or pass through |
| **Downstream** | MCP client and Connector | Formal OAuth, or identity headers from the Formal Endpoint |

## Upstream authentication

When you add a server, you choose which credential Formal sends to it. The admin Catalog offers three options:

| Option | Credential sent upstream |
| - | - |
| **Linked accounts** | Each user's own OAuth token for the server |
| **Shared credentials** | A credential you provide, such as an API key |
| **Pass through** | Whatever the client sends |

### Linked accounts (OAuth)

Each user links their own account for the server once. Formal stores the token encrypted in its control plane and refreshes it when the server supports refresh. The Connector adds the token to each request after policies allow it. Users never see the token.

Users link accounts on the server's page in the Catalog. They can also link during OAuth sign-in from an MCP client.

Formal needs an OAuth client to sign users in to the server. Choose one under **OAuth app**:

<AccordionGroup>
  <Accordion title="Formal's app (Client ID Metadata Document)" icon="id-card">
    Formal identifies itself with a [Client ID Metadata Document](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#client-id-metadata-documents) (CIMD). Its client ID is a URL where Formal hosts its name and redirect URI. You don't register anything with the server.

    The server's authorization server must:

    * Advertise `client_id_metadata_document_supported: true`.
    * Support the authorization code flow with PKCE `S256`.
    * Accept public clients, with token endpoint auth method `none`.

    Linear, Notion, Sentry, Canva, Grafana Cloud, Fireflies, Exa, and Pylon met these requirements at the time of writing. To check another server, read its authorization server metadata:

    ```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
    curl -s https://mcp.linear.app/.well-known/oauth-authorization-server \
      | jq '{client_id_metadata_document_supported, code_challenge_methods_supported, token_endpoint_auth_methods_supported}'
    # Expected: client_id_metadata_document_supported is true, and "none" is listed
    ```
  </Accordion>

  <Accordion title="Your OAuth app (pre-registered)" icon="key">
    Use this for servers without CIMD support, such as GitHub and Slack.

    1. Register an OAuth app with the server's provider.
    2. Copy the **Redirect URI** from the Catalog into the app's allowed redirect URIs.
    3. Enter the app's **Client ID** and, if it has one, its **Client secret**.

    Formal encrypts the secret and never shows it again. Leave the secret empty for a public client, which uses PKCE alone.
  </Accordion>
</AccordionGroup>

To set OAuth scopes, use **Permissions to request**. Leave it empty to get the server's default access.

<Warning>
  If you change the scopes or the OAuth app, Formal unlinks every account. Users must link again.
</Warning>

If a user hasn't linked an account, the Connector rejects their requests with a pointer to the server's Catalog page.

### Shared credentials

Formal adds one credential that you provide, such as an API key or a service account token. Users never see it.

You store the credential as a [native user](/docs/guides/core-concepts/resources/native-users) on the server's resource, and Formal sends the default one. To send different credentials to different people, add [selection rules](/docs/guides/core-concepts/resources/native-users#select-a-default-native-user) on the resource page.

Use shared credentials for servers that accept API keys, or when everyone should act as one account.

### Pass through

Clients send their own sign-in or API key, and Formal forwards it unchanged. Formal stores nothing.

With pass through, the client's own credential goes to the server, and nothing in the request identifies the Formal user. Formal can only attribute these requests when they come through the [Formal Endpoint](#identity-headers), which adds the user's identity. For that reason, pass-through servers have no Connector address in the Catalog.

## Downstream authentication

Clients identify the user to the Connector in one of two ways. Formal uses that identity for policies, logs, and choosing the upstream credential.

| Method | Used by | Available when |
| - | - | - |
| **Formal OAuth** | Any MCP client that supports CIMD | Upstream is linked accounts or shared credentials |
| **Identity headers** | The Formal Endpoint | Any upstream option |

### Formal OAuth

When upstream authentication isn't pass through, the Connector acts as an OAuth protected resource:

```mermaid theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
sequenceDiagram
    participant Client as MCP client
    participant Connector as Connector
    participant Formal as Formal (Catalog + OAuth)
    participant Server as MCP server

    Client->>Connector: MCP request (no token)
    Connector-->>Client: 401 with protected resource metadata
    Client->>Formal: OAuth sign-in and consent
    Formal-->>Client: Access token for this server
    Client->>Connector: MCP request with Formal token
    Connector->>Server: Request with upstream credential
    Server-->>Connector: Response
    Connector-->>Client: Response
```

1. The Connector answers a request without a token with `401 Unauthorized`. Its `WWW-Authenticate` header points to `/.well-known/oauth-protected-resource`.
2. Formal is listed in that document as the authorization server.
3. The client starts an authorization code flow with Formal. It identifies itself with its own CIMD URL.
4. The user signs in to Formal and approves the client on a Catalog consent screen. If the server uses linked accounts, the user links their account here too.
5. Formal issues a short-lived access token bound to this server's address, plus a refresh token.

The Connector validates access tokens offline and removes them before forwarding the request upstream. Formal rotates refresh tokens on each use and keeps them in its control plane.

Formal's authorization server accepts only public clients that use PKCE `S256`. It doesn't support Dynamic Client Registration (DCR). Clients that only support DCR can't sign in this way. On a device, route them through the Endpoint with [identity headers](#identity-headers).

Users can review and revoke approved clients on the server's page in the Catalog. After a revoke, Formal stops refreshing that client's tokens, but access tokens already issued stay valid until they expire.

### Identity headers

The Connector also accepts a Formal identity in two headers:

* `X-Formal-User-Username`: the Formal username, such as `idp:formal:human:alice@example.com`
* `X-Formal-User-Password`: a Formal-signed token for that user

The [Formal Endpoint](/docs/guides/client-apps/desktop-app#transparent-mode) adds both headers for MCP clients on the device. Users keep the server's original URL, such as `https://mcp.linear.app/mcp`. They don't sign in again, because the Endpoint already knows who they are.

To route Endpoint traffic to the gateway, create a [network rule](/docs/guides/network-rules) that forwards MCP traffic from AI agents to the Connector:

```cel theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
{
  "condition": {
    "pre_tcp": process.is_agent,
    "pre_tls": process.is_agent && resource.technology == "mcp",
    "post_tls": true
  },
  "outputs": {
    "forward_to_connector": true
  }
}
```

The rule matches requests from [known agents](/docs/guides/network-rules#known-agent-identities) when the destination is an MCP server in Formal. Checking the destination in `pre_tls` keeps Formal from terminating TLS on the agents' other traffic. Servers you add later are covered without editing the rule. To cover other applications, widen the process conditions.

## Deploy the Connector

Before you add servers, set up the following on the Connector:

1. A listener that routes MCP traffic.
2. A hostname.
3. DNS and TLS that cover every subdomain of that hostname (i.e., `*.<connector-hostname>`), to enable [smart routing](/docs/guides/core-concepts/resources/smart-routing). A request to `linear.acme.connectors.joinformal.com` routes to the server named `linear`.

### 1. Add an MCP listener

Create a listener with a **Technology** rule set to **MCP**. Use port `443` so client URLs don't need a port.

<Tabs>
  <Tab title="Control Plane">
    1. Go to [Connectors](https://app.formal.ai/connectors) and open your Connector.
    2. Add a listener on port `443`.
    3. Add a rule with type **Technology** and technology **MCP**.
  </Tab>

  <Tab title="Terraform">
    ```hcl theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
    resource "formal_connector_listener" "mcp" {
      connector_id = formal_connector.main.id
      name         = "mcp-gateway"
      port         = 443
    }

    resource "formal_connector_listener_rule" "mcp" {
      connector_listener_id = formal_connector_listener.mcp.id
      type                  = "technology"
      rule                  = "mcp"
    }
    ```
  </Tab>
</Tabs>

With a technology rule, the Connector routes every MCP server it can see. To expose only some servers, use **Resource** rules instead. See [Listeners and Rules](/docs/guides/core-concepts/connectors/listeners).

A Connector in a [Space](/docs/guides/core-concepts/spaces) only routes servers in that Space. A Connector without a Space routes all of them.

### 2. Add a hostname with wildcard DNS and TLS

Clients reach each server at a subdomain of the Connector hostname, so both DNS and the TLS certificate must cover `*.<connector-hostname>`.

<Tabs>
  <Tab title="Formal-managed">
    Use a hostname ending in `.<your-org>.connectors.joinformal.com`. Formal creates both the hostname and wildcard DNS records. It also issues a wildcard certificate.

    ```hcl theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
    resource "formal_connector_hostname" "main" {
      connector_id = formal_connector.main.id
      hostname     = "gateway.<your-org>.connectors.joinformal.com"
      dns_record   = "<LOAD_BALANCER_DNS_NAME>"
    }
    ```
  </Tab>

  <Tab title="Customer-managed">
    Use a hostname in a DNS zone you control. You must:

    1. Create a DNS record for `*.<connector-hostname>` that points to your load balancer.
    2. Upload a certificate whose subject alternative names include `*.<connector-hostname>`.

    ```hcl theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
    resource "formal_connector_hostname" "main" {
      connector_id = formal_connector.main.id
      hostname     = "mcp.example.com"
      certificate  = file("wildcard-mcp-example-com.pem")
      private_key  = file("wildcard-mcp-example-com-key.pem")
    }
    ```
  </Tab>
</Tabs>

See [Hostname and TLS Configuration](/docs/guides/core-concepts/connectors/tls) for the full TLS options.

<Warning>
  Claude and ChatGPT call MCP servers from Anthropic's and OpenAI's clouds. To use them, expose the Connector's listener to the internet.
</Warning>

**Verify:**

```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
HOST="gateway.<your-org>.connectors.joinformal.com"

# Any subdomain should resolve to your load balancer
dig +short "linear.$HOST"

# The certificate should list *.<connector-hostname>
openssl s_client -connect "linear.$HOST:443" -servername "linear.$HOST" </dev/null 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName
# Expected: DNS:*.gateway.<your-org>.connectors.joinformal.com
```

## Grant access to the Catalog

Users finish setup in the employee Catalog at [catalog.formal.ai](https://catalog.formal.ai). There, they approve MCP clients during OAuth sign-in and link their upstream accounts. In [Permissions](/docs/guides/core-concepts/permissions), you control these calls through the **Catalog** application.

If a permission uses `default allow := false`, add a rule for the Catalog to that same permission:

```rego theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
package formal.app

import future.keywords.if
import future.keywords.in

default allow := false

# ...your existing rules...

allow if {
  input.app.name == "Catalog"
}
```

To limit the Catalog to one group, add a permission that denies everyone else:

```rego theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
package formal.app

import future.keywords.if
import future.keywords.in

default allow := true

allow := false if {
  input.app.name == "Catalog"
  not "mcp-users" in input.user.groups
}
```

Keep these constraints in mind:

* Only active human users can approve an MCP client. Machine users authenticate with [identity headers](#identity-headers) instead.
* Administrators who add servers need the **Mcp** and **Resource** applications. To edit **Access Controls** on a server, they also need **Policies**.

## Add an MCP server

1. Go to [Catalog](https://app.formal.ai/catalog) and click **Add MCP**.
2. Pick a template, or click **Add Custom** and enter the server's URL.
3. Enter a **Name**. Formal uses it as the server's subdomain on the Connector.
4. Under **Authentication**, choose how Formal authenticates to the server. See [Upstream authentication](#upstream-authentication).
5. Save the server.

<Note>
  Use a valid DNS label for the name: lowercase letters, digits, and hyphens, up to 63 characters. Formal can't give a server with any other name a Connector address.
</Note>

You can also create the server with Terraform. Set the `mcp-endpoint` tag to the server's MCP path:

```hcl theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
resource "formal_resource" "linear" {
  name       = "linear"
  technology = "mcp"
  hostname   = "mcp.linear.app"
  port       = 443
  tags = {
    "mcp-endpoint" = "/mcp"
  }
}
```

Then set its authentication in the Control Plane:

1. Open the server in the [Catalog](https://app.formal.ai/catalog).
2. On the **Configuration** tab, under **Authentication**, choose an option for **How does Formal authenticate to this server?**
3. For **Linked accounts**, choose an **OAuth app**. For **Shared credentials**, click **Add credential**.
4. Click **Save authentication**.

**Verify:**

1. Open the server in the Catalog. On its **Configuration** tab, you'll see a **Connector address**, such as `https://linear.gateway.<your-org>.connectors.joinformal.com/mcp`. If the address is missing, the reason appears in its place.
2. If you chose linked accounts or shared credentials, confirm that Formal is listed as the authorization server:

```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
curl -s https://linear.gateway.<your-org>.connectors.joinformal.com/.well-known/oauth-protected-resource | jq .
# Expected: "resource" is the server's address, and "authorization_servers" lists Formal
```

## Connect MCP clients

Pick the URL based on where the client runs:

| Client runs | Uses | Example |
| - | - | - |
| In the vendor's cloud (Claude, ChatGPT) | The Connector address | `https://linear.<connector-hostname>/mcp` |
| On a device with the Formal Endpoint | The server's original URL | `https://mcp.linear.app/mcp` |
| On a device without the Endpoint | The Connector address | `https://linear.<connector-hostname>/mcp` |

Users can find the right URL and setup steps for over 40 clients on each server's page in the [Catalog](https://catalog.formal.ai). The steps account for whether their device runs the Endpoint.

In the examples below, the server is named `linear`, and the Connector hostname is `gateway.acme.connectors.joinformal.com`.

### Claude and ChatGPT

Claude and ChatGPT call MCP servers from Anthropic's and OpenAI's clouds, so they always use the Connector address. A workspace owner adds the server once for the organization. Each member then connects with their own Formal account.

The **Add to Claude** and **Add to ChatGPT** buttons appear under **Add to your organization's AI apps** in the server's **Authentication** section. You'll see them once the server uses linked accounts or shared credentials and has a Connector address.

<Tabs>
  <Tab title="Claude">
    To add the server for your organization:

    1. In the [Catalog](https://app.formal.ai/catalog), open the server and go to the **Configuration** tab.
    2. Under **Add to your organization's AI apps**, click **Add to Claude**. Claude opens its organization connector settings with the server's name and Connector address filled in.
    3. As a Claude Owner, confirm the connector.

    Each member then connects:

    1. In Claude, open **Settings** → **Connectors**, find the server, and click **Connect**.
    2. Sign in to Formal and approve Claude on the Catalog consent screen.
    3. In a new conversation, enable the connector from the tools menu.
  </Tab>

  <Tab title="ChatGPT">
    To add the server for your organization:

    1. In the [Catalog](https://app.formal.ai/catalog), open the server and go to the **Configuration** tab.
    2. Under **Add to your organization's AI apps**, click **Add to ChatGPT**. Formal copies the Connector address and opens your workspace's app settings in ChatGPT.
    3. Choose **Create**, paste the address as the MCP server URL, and publish the app.

    Each member then connects:

    1. In ChatGPT, find the app under **Settings** → **Apps** and click **Connect**.
    2. Sign in to Formal and approve ChatGPT on the Catalog consent screen.
    3. In a new chat, enable the app from the tools picker.
  </Tab>
</Tabs>

If you restrict inbound traffic to the Connector, allow the IP ranges that Anthropic and OpenAI publish for outbound connector traffic.

### Claude Code

<Tabs>
  <Tab title="With the Formal Endpoint">
    Use the server's original URL. The Endpoint routes it through the gateway and adds your identity.

    ```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
    claude mcp add --transport http linear https://mcp.linear.app/mcp
    ```

    If the server uses linked accounts, link your account in the Catalog first.
  </Tab>

  <Tab title="Without the Formal Endpoint">
    Use the Connector address:

    ```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
    claude mcp add --transport http linear https://linear.gateway.acme.connectors.joinformal.com/mcp
    ```

    Start Claude Code, run `/mcp`, and select `linear`. Sign in to Formal in your browser and approve Claude Code.
  </Tab>
</Tabs>

**Verify:**

```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
claude mcp get linear
# Expected: linear shows as connected
```

### Codex

<Tabs>
  <Tab title="With the Formal Endpoint">
    Use the server's original URL:

    ```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
    codex mcp add linear --url https://mcp.linear.app/mcp
    ```

    If the server uses linked accounts, link your account in the Catalog first.
  </Tab>

  <Tab title="Without the Formal Endpoint">
    Use the Connector address, then sign in:

    ```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
    codex mcp add linear --url https://linear.gateway.acme.connectors.joinformal.com/mcp
    codex mcp login linear
    ```

    Sign in to Formal in your browser and approve Codex.
  </Tab>
</Tabs>

**Verify:**

```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
codex mcp list
# Expected: linear is listed and enabled
```

Then ask Codex to list the server's tools. Each call appears in [Logs](https://app.formal.ai/logs) with your Formal identity.

## Governance

Every request through the gateway carries a Formal identity, so you can control who uses each server and which tools they can call.

### Restrict access and tools

1. Go to [Catalog](https://app.formal.ai/catalog) and open the server.
2. Open the **Access Controls** tab.
3. Under **Who can use this MCP?**, choose **Everyone**, **Specific users**, or **No one**. With **Specific users**, add users and groups.
4. Under **Which tools can they use?**, choose **All tools** or **Specific tools**. With **Specific tools**, select the allowed tools. Use the **Read-only** and **Destructive** filters to find tools by the hints each server publishes.
5. Optionally, edit **Message shown when access is denied**.
6. Click **Save changes**.

Formal saves these controls as a policy named `MCP access controls: <server-name>`. The Connector blocks requests from anyone outside the allowed users and groups. It also blocks `tools/call` requests for tools outside the list. Clients may still list those tools, but calls to them fail with the block message.

<Note>
  The **Access Controls** tab shows only the policy it created. Other policies can also restrict the server.
</Note>

### Write custom policies

For rules the tab doesn't cover, write a [policy](/docs/guides/policies/introduction) on MCP traffic. Policies can read each call's tool name and parameters. See [MCP resources](/docs/guides/core-concepts/resources/mcp#policy-evaluation) for an example and [MCP policy inputs](/docs/guides/policies/evaluation#mcp-resources) for every field.

### Review usage

Tool calls appear in [Logs](https://app.formal.ai/logs) with the user, server, and tool name. In the Catalog, a server's **Usage** tab breaks down its requests by tool and by user.

## Troubleshooting

<AccordionGroup>
  <Accordion title="No Connector address in the Catalog">
    **Possible causes:**

    * The Connector has no hostname, or no listener routes MCP
    * The Connector is in a different Space from the server
    * The server name isn't a valid DNS label

    **Fix:** Fix the cause listed in the Catalog, then reload the server.
  </Accordion>

  <Accordion title="TLS or certificate errors in the client">
    **Possible causes:**

    * The certificate covers only the bare hostname, not `*.<connector-hostname>`
    * No wildcard DNS record exists

    **Fix:** Re-run the `dig` and `openssl` checks in [Deploy the Connector](#deploy-the-connector).
  </Accordion>

  <Accordion title="Invalid client error during sign-in">
    **Possible causes:**

    * The client doesn't support Client ID Metadata Documents
    * The client's redirect URI isn't in its metadata document

    **Fix:** Use the Formal Endpoint or identity headers for that client.
  </Accordion>

  <Accordion title="403 Forbidden on the consent screen">
    **Cause:** A permission denies the **Catalog** application for this user.

    **Fix:** Allow `input.app.name == "Catalog"` in every permission that uses `default allow := false`. See [Grant access to the Catalog](#grant-access-to-the-catalog).
  </Accordion>

  <Accordion title="Account linking fails">
    **Possible causes:**

    * The server doesn't support CIMD with public clients
    * A pre-registered app is missing Formal's redirect URI

    **Fix:** Run the CIMD metadata check in [Linked accounts](#linked-accounts-oauth). If the server doesn't support CIMD, switch to **Your OAuth app**.
  </Accordion>

  <Accordion title="Tool calls fail with a message to link an account">
    **Cause:** The server uses linked accounts, and the user hasn't linked one, or the link expired.

    **Fix:** Link or re-link the account on the server's page in the Catalog.
  </Accordion>
</AccordionGroup>

## Next Steps

<CardGroup cols={2}>
  <Card title="MCP Resources" icon="robot" href="/docs/guides/core-concepts/resources/mcp">
    Write policies on MCP tool calls
  </Card>

  <Card title="Native Users" icon="user" href="/docs/guides/core-concepts/resources/native-users">
    Manage shared credentials
  </Card>

  <Card title="Network Rules" icon="filter" href="/docs/guides/network-rules">
    Route Endpoint traffic to the Connector
  </Card>

  <Card title="Permissions" icon="shield-check" href="/docs/guides/core-concepts/permissions">
    Control who can use the Catalog
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.