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

# Custom MCP server

> Add any remote MCP server to Formal by URL, then control and log its tool calls.

Custom

Add any remote MCP server that isn't in the Catalog, then route its traffic through Formal to control and log what AI agents do with it:

* **Block** risky tools, such as `delete_record`.
* **Mask** emails and phone numbers in `search_records` responses.
* **Keep credentials at the Connector**, so users and agents never see them.
* **Log** every tool call with the user, MCP client, and parameters.

Cloud agents

Laptop / VM

Endpoint

[https://mcp.internal.example.com/mcp](https://mcp.internal.example.com/mcp)

Connector

MCP

## 0. Prepare your Connector

The Connector needs an MCP listener and a hostname with wildcard DNS and TLS.
Set them up once by following [Deploy the Connector](https://docs.formal.ai/docs/guides/core-concepts/connectors/mcp-gateway.md#deploy-the-connector). Every MCP server reuses them.

## 1. Add your custom MCP server to your Resources

> [tab] UI
>
> 1. Go to the [Admin Catalog](https://app.formal.ai/catalog) and click **Add MCP**.
> 2. Click **Add Custom**.
> 3. Paste `https://mcp.internal.example.com/mcp` into **URL**.
> 4. Enter `internal-tools` in **Name**. Formal uses it as the server's subdomain on the Connector.
> 5. Choose the **Space** containing your Connector.
> 6. Choose **Authentication**, then click **Add to Catalog**.
>
> Use a valid DNS label with lowercase letters, digits, and hyphens. Names can contain up to 63 characters.
>
> Adding a custom MCP server to the Admin Catalog

> [tab] Terraform
>
> Set `space_id` to the ID of the Space containing your Connector.
>
> ```hcl theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> resource "formal_resource" "internal_tools" {
>   name                    = "internal-tools"
>   technology              = "mcp"
>   hostname                = "mcp.internal.example.com"
>   space_id                = "<SPACE_ID>"
>   native_users_v3_enabled = true
>   port                    = 443
>   tags = {
>     "mcp-endpoint" = "/mcp"
>   }
> }
>
> resource "formal_resource_oauth_settings" "internal_tools" {
>   resource_id = formal_resource.internal_tools.id
> }
> ```

> [tab] Pulumi
>
> Set `spaceId` to the ID of the Space containing your Connector.
>
> ```ts theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> import * as formal from "@formalco/pulumi";
>
> const resource = new formal.Resource("mcp", {
>   name: "internal-tools",
>   technology: "mcp",
>   hostname: "mcp.internal.example.com",
>   port: 443,
>   spaceId: "<SPACE_ID>",
>   tags: { "mcp-endpoint": "/mcp" },
>   nativeUsersV3Enabled: true,
> });
>
> new formal.ResourceOauthSettings("mcpOAuth", {
>   resourceId: resource.id,
> });
> ```

> [tab] API
>
> Create a Resource with the MCP endpoint and its Space ID.
>
> ```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> export FORMAL_API_KEY="<YOUR_API_KEY>"
> RESOURCE_RESPONSE=$(curl --fail --silent --show-error \
>   --request POST \
>   https://api.formal.ai/core.v1.ResourceService/CreateResource \
>   --header "x-api-key: ${FORMAL_API_KEY}" \
>   --header "Content-Type: application/json" \
>   --data-binary @- <<JSON
>   {
>     "name": "internal-tools",
>     "hostname": "mcp.internal.example.com",
>     "port": 443,
>     "technology": "mcp",
>     "spaceId": "<SPACE_ID>",
>     "tags": [
>       {
>         "key": "mcp-endpoint",
>         "value": "/mcp"
>       }
>     ]
>   }
> JSON
> )
> RESOURCE_ID=$(printf '%s' "${RESOURCE_RESPONSE}" | jq -r '.resource.id')
> curl --fail --silent --show-error \
>   --request POST \
>   https://api.formal.ai/core.v1.ResourceService/CreateResourceOAuthSettings \
>   --header "x-api-key: ${FORMAL_API_KEY}" \
>   --header "Content-Type: application/json" \
>   --data-binary @- <<JSON
>   {
>     "resourceId": "${RESOURCE_ID}"
>   }
> JSON
> ```

## 2. Setup Authentication

Choose which credential Formal sends to the server. See [Upstream authentication](https://docs.formal.ai/docs/guides/core-concepts/connectors/mcp-gateway.md#upstream-authentication) for details.

> [tab] Linked Accounts
>
> Each user links their own OAuth account once. Formal stores the token encrypted and refreshes it when the server supports refresh.
> The Connector adds the token to requests after policies allow them. Users never see the token.
>
> Formal's app uses an app registered with the provider, when available. Otherwise, it can use a Client ID Metadata Document (CIMD) when supported.
> For CIMD, the server's authorization server must advertise `client_id_metadata_document_supported: true`.
> It must support authorization code with PKCE `S256` and public clients with token endpoint authentication `none`.
>
> Formal checks the server when you save. If neither option works, register your own OAuth app with the provider.
> Add the Catalog's **Redirect URI** to its allowed redirects. Then enter the app's **Client ID** and optional **Client secret**.

> [tab] Shared Credentials
>
> Formal sends one credential you provide, such as an API key or service account token.
> Store it as a [native user](https://docs.formal.ai/docs/guides/core-concepts/resources/native-users.md) on the server's resource.
> Formal sends the default credential, and users never see it.
> Use [selection rules](https://docs.formal.ai/docs/guides/core-concepts/resources/native-users.md#select-a-default-native-user) to send different credentials.

> [tab] Pass Through
>
> Clients send their own sign-in or API key, and Formal forwards it unchanged without storing it.
> The request does not identify the Formal user.
> Formal can attribute requests only through the [Formal Endpoint](https://docs.formal.ai/docs/guides/core-concepts/connectors/mcp-gateway.md#identity-headers), which adds user identity.
> Pass-through servers have no Connector address in the Catalog.

## 3. Route Endpoint traffic

Auth is managed at the Connector with Linked Accounts or Shared Credentials. The Endpoint forwards MCP traffic to it.
Laptops and VMs with the Formal Endpoint keep using `https://mcp.internal.example.com/mcp`.
A network rule sends that traffic to the Connector.

> [tab] UI
>
> 1. Go to [Network Rules](https://app.formal.ai/network-rules) and create a rule named `internal-tools-mcp`.
> 2. Paste this expression, set the rule to **Active**, then save:
>
> ```javascript theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> {
>   "condition": {
>     "pre_tcp": process.is_agent,
>     // Every MCP server added to Formal: process.is_agent && resource.technology == "mcp"
>     "pre_tls": process.is_agent && hostname == "mcp.internal.example.com",
>     "post_tls": true
>   },
>   "outputs": {
>     "forward_to_connector": true
>   }
> }
> ```

> [tab] Terraform
>
> ```hcl theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> resource "formal_network_rule" "internal_tools_mcp" {
>   name        = "internal-tools-mcp"
>   description = "Forward custom MCP traffic from agents to the Connector"
>   status      = "active"
>
>   # Every MCP server added to Formal: set pre_tls to process.is_agent && resource.technology == "mcp"
>   cel_expression = <<-EOT
>     {
>       "condition": {
>         "pre_tcp": process.is_agent,
>         "pre_tls": process.is_agent && hostname == "mcp.internal.example.com",
>         "post_tls": true
>       },
>       "outputs": {
>         "forward_to_connector": true
>       }
>     }
>   EOT
> }
> ```

> [tab] Pulumi
>
> ```ts theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> import * as formal from "@formalco/pulumi";
>
> new formal.NetworkRule("internalToolsMcp", {
>   name: "internal-tools-mcp",
>   description: "Forward custom MCP traffic from agents to the Connector",
>   status: "active",
>   // Every MCP server added to Formal: set pre_tls to process.is_agent && resource.technology == "mcp"
>   celExpression: `
>     {
>       "condition": {
>         "pre_tcp": process.is_agent,
>         "pre_tls": process.is_agent && hostname == "mcp.internal.example.com",
>         "post_tls": true
>       },
>       "outputs": {
>         "forward_to_connector": true
>       }
>     }
>   `,
> });
> ```

> [tab] API
>
> ```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> export FORMAL_API_KEY="<YOUR_API_KEY>"
> # Every MCP server added to Formal: set pre_tls to process.is_agent && resource.technology == "mcp"
> CEL_EXPRESSION=$(cat <<'CEL'
> {
>   "condition": {
>     "pre_tcp": process.is_agent,
>     "pre_tls": process.is_agent && hostname == "mcp.internal.example.com",
>     "post_tls": true
>   },
>   "outputs": {
>     "forward_to_connector": true
>   }
> }
> CEL
> )
> jq -n --arg cel "${CEL_EXPRESSION}" '{
>   name: "internal-tools-mcp",
>   description: "Forward custom MCP traffic from agents to the Connector",
>   status: "active",
>   celExpression: $cel
> }' | curl --fail --silent --show-error \
>   --request POST \
>   https://api.formal.ai/core.v1.DesktopService/CreateDesktopRoutingRule \
>   --header "x-api-key: ${FORMAL_API_KEY}" \
>   --header "Content-Type: application/json" \
>   --data-binary @-
> ```

The rule matches [known agents](https://docs.formal.ai/docs/guides/network-rules.md#known-agent-identities) only.
See [Identity headers](https://docs.formal.ai/docs/guides/core-concepts/connectors/mcp-gateway.md#identity-headers).

Cloud agents such as Claude, ChatGPT, and Cursor Cloud Agents don't run the Endpoint.
Point them at this server's Connector address, `https://internal-tools.<connector-hostname>/mcp`.
Find it in the [Admin Catalog](https://app.formal.ai/catalog): open Internal Tools, then copy **Connector address** on **Configuration**.

## 4. Create policies

Use the tool names your MCP server exposes when you write policies.

### Policies examples

> [tab] Block a tool
>
> This policy blocks tools that change data, so agents can only read Internal Tools.
>
> ```rego theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> package formal.v2
>
> import future.keywords.if
> import future.keywords.in
>
> write_tools := {
>   "delete_record",
>   "update_record",
> }
>
> request := {
>   "action": "block",
>   "type": "block_with_custom_message",
>   "message": "Writing to Internal Tools is not allowed.",
> } if {
>   input.resource.name == "internal-tools"
>   input.mcp.method == "tools/call"
>   input.mcp.tool_name in write_tools
> }
> ```

> [tab] Mask a tool's output
>
> This policy masks emails and phone numbers in `search_records` results.
>
> ```rego theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> package formal.v2
>
> import future.keywords.if
>
> email_pattern := `[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`
> phone_pattern := `\+?[0-9][0-9().\s-]{7,}[0-9]`
>
> response := {
>   "action": "rewrite",
>   "body": regex.replace(regex.replace(input.http.body, email_pattern, "[REDACTED-EMAIL]"), phone_pattern, "[REDACTED-PHONE]"),
>   "reason": "Mask emails and phone numbers in search results",
> } if {
>   input.resource.name == "internal-tools"
>   input.mcp.tool_name == "search_records"
>   regex.match(email_pattern, input.http.body) or regex.match(phone_pattern, input.http.body)
> }
> ```

See [MCP resources](https://docs.formal.ai/docs/guides/policies/evaluation.md#mcp-resources) for `input.mcp` fields and [Rewrite Action](https://docs.formal.ai/docs/guides/policies/enforcement.md#rewrite-action) for body rewrites.

## 5. Check the logs

Open [Logs](https://app.formal.ai/logs) and search for `resource.name:internal-tools`.
Each tool call is one `request` log with the user, MCP client, and tool.
Open a log to see its tool parameters and the policies it triggered.
A blocked write shows the block policy and its message.
A masked search shows the rewrite policy.

Internal Tools tool calls in Logs, including a masked response and a blocked write

See [Logs](https://docs.formal.ai/docs/guides/observability/logs.md) for search syntax and filters.

## Troubleshooting

> [accordion] The agent says Formal has no current sign-in
>
> The agent says this server has no current sign-in. Link or re-link your account in Formal.
>
> **Cause:** You never linked an account, or its token expired and could not be refreshed.
>
> **Fix:** Open the server in the [Employee Catalog](https://catalog.formal.ai) and link your account again.

> [accordion] The server rejected your credentials
>
> The agent says the server rejected your credentials. Link or re-link your account in Formal.
>
> **Cause:** The server no longer accepts the linked token, for example after access was revoked.
>
> **Fix:** Link your account again from the [Employee Catalog](https://catalog.formal.ai).
>
> With Shared Credentials, replace the credential on the server's **Shared Credentials** configuration.

> [accordion] Laptop traffic doesn't appear in Logs
>
> **Cause:** The Endpoint isn't running, or the network rule doesn't match the client.
>
> **Fix:** Check that the Endpoint is connected. The rule only matches [known agents](https://docs.formal.ai/docs/guides/network-rules.md#known-agent-identities).
> Other clients reach `mcp.internal.example.com` directly unless the rule also matches them.
> See [Network rules](https://docs.formal.ai/docs/guides/network-rules.md).

> [accordion] MCP clients can't connect directly yet
>
> **Cause:** The **Configuration** tab lists the reason, often `No Connector is in this server's Space.`
>
> **Fix:** Edit the server and choose the Space containing your Connector.

> [accordion] The server has no Connector address
>
> **Cause:** The server name isn't a valid DNS label.
>
> **Fix:** Recreate the server with a valid DNS-label name.

## Next Steps

> [card] [MCP Gateway](https://docs.formal.ai/docs/guides/core-concepts/connectors/mcp-gateway.md)
>
> Configure Connector routing and downstream access

> [card] [All MCP servers](https://docs.formal.ai/docs/guides/core-concepts/connectors/mcp-servers/index.md)
>
> Browse every MCP server template


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