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

# Build Your Own MCP Tools

> Add domain connection to your own MCP server with two tools built on Entri's Sharing Links API.

## Overview

If your platform already runs an MCP server, you can add domain connection to it directly instead of asking your users to install a second server. You build two tools on top of Entri's Sharing Links API: `connect_domain` creates a link where the user sets up their domain, and `get_domain_connection_status` reports whether the domain works. Your server holds the Entri credentials, resolves the DNS records for the current user, and decides what the agent sees.

That last part matters as much as the API calls. The agent only knows what your tool names, descriptions, and results tell it, so this guide covers both the Entri endpoints each tool calls and how to shape the tools so the agent picks the right one and acts correctly on the result. If you would rather not build tools, the hosted [Entri MCP server](https://developers.entri.com/entri-mcp.md) exposes ready-made ones.

## How it works

1. The user asks the agent to connect a domain, and the agent calls `connect_domain` with it.
2. Your server creates a link with the [Sharing Links API](https://developers.entri.com/connect/shared-links.md#sharing-links-api), with the domain and the DNS records your platform needs, and returns it.
3. The agent sends the link to the user. On that page, Entri detects the DNS provider. If Entri can configure it, the user signs in and Entri applies the records. If not, the page shows the records to add by hand and links to your setup guide.
4. When the user says they are done, the agent calls `get_domain_connection_status`, and your server reads the [flow status](https://developers.entri.com/connect/sdk.md#check-flow-status) to tell the agent where the setup stands.

```mermaid theme={"system"}
flowchart TD
    A["User: connect example.com"] --> B["connect_domain"]
    B --> C["POST /sharing/connect"]
    C --> D["Agent sends the link"]
    D --> E{"Entri's page"}
    E -- "Automatic setup" --> F["User signs in, Entri applies the records"]
    E -- "Manual setup" --> G["User adds the records by hand"]
    F --> H["get_domain_connection_status"]
    G --> H
    H --> I["GET /connect/job_id/status"]
```

> [!NOTE]
>
> The Sharing Links API response contains only `link` and `job_id`. It does not say whether the user will get an automatic or a manual setup, because the modal decides that when the user opens the link. If you would rather handle manual setups with your own guide, you can optionally check the domain with `/checkdomain` before you create the link, as described in [Handle manual setups yourself](#handle-manual-setups-yourself-optional).

## Before you start

| Requirement | Details |
| - | - |
| Application credentials | Your `applicationId` and `secret` from the Entri Dashboard. Keep the secret on your server. |
| Connection storage | One record per user and domain with the link, the `job_id`, and whether the domain connected. |
| A DNS setup guide (optional) | A page that explains how to add DNS records by hand. When a user gets a manual setup, the modal links to it through [`manualSetupDocumentation`](https://developers.entri.com/connect/configuration). |

The Sharing Links API validates the link config against your plan, so the features you use in it, such as `prefilledDomain`, must be included in your plan. Otherwise the request fails with `Features [...] are disabled on your account`.

## Design the tools for the agent

An agent chooses a tool from its name, description, and input schema, and decides what to do next from the result. Treat all four as prompts. The patterns below cover the first three, and each tool section shows the results the agent gets back.

### Expose intents, not endpoints

Two tools that match what users ask for ("connect my domain", "is my domain working?") work better than one tool per API call. `connect_domain` loads the user's records, creates the link, and stores the `job_id` in one call, so the agent never orchestrates API calls or passes IDs between them. Any logic you add later, such as the [optional domain check](#handle-manual-setups-yourself-optional), belongs inside the tools too, so the right behavior is the only one available.

Name the tools after the intent as well. Users never ask for a "sharing link", so a tool called `create_sharing_link` matches their words poorly. `connect_domain` and `get_domain_connection_status` match how people phrase the request.

### Write descriptions for the model

A description should say what the tool does, when to use it, when not to, and what happens after. Include a few phrases users actually say. Tool definitions are part of the agent's context on every turn, so keep descriptions to a few sentences.

If your server has other domain tools, for example one that buys domains or one that adds a domain to a project, say in each description when the other tool is the right one so the agent can tell them apart. If your platform needs the domain registered on its side before DNS is configured, do that inside `connect_domain` rather than relying on the agent to call two tools in the right order.

### Keep inputs to what the model knows

The only input of both tools is the domain. The model never supplies DNS records, your `applicationId`, or tokens. Your server resolves the records for the authenticated user, so the agent cannot invent or change them. The model never handles the `job_id` either. Agents lose opaque IDs in long conversations and between sessions, so your server stores it and looks it up by user and domain. Normalize the domain on your server, because users paste URLs: `https://Example.com/shop` should become `example.com`.

## Shared code

The examples use TypeScript and the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk). The same structure works with any MCP SDK. The [full example](#full-example) at the end of this page puts every part in one file.

### Your platform

These declarations are the parts that depend on your platform. Replace them with your own code. `dnsRecordsFor` returns the records in the same format as the [`dnsRecords`](https://developers.entri.com/connect/dns-records) config key.

```ts theme={"system"}
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { z } from "zod";

const ENTRI_API = "https://api.goentri.com";
const APPLICATION_ID = process.env.ENTRI_APPLICATION_ID!;
const ENTRI_SECRET = process.env.ENTRI_SECRET!;
// Optional: your own guide for adding DNS records by hand. The modal links to it.
const SETUP_GUIDE_URL = "https://help.example.com/connect-your-domain";

type DnsRecord = { type: string; host: string; value: string; ttl: number; priority?: number };

type Connection = { domain: string; link: string; jobId: string; connected: boolean };

// The user behind the MCP request, from your own MCP authentication.
declare function currentUserId(authInfo?: AuthInfo): string;
// The records this user's site needs, in Entri's dnsRecords format.
declare function dnsRecordsFor(userId: string, domain: string): Promise<DnsRecord[]>;
// One connection per user and domain, stored in your database.
// Delete it when the user removes the domain, so that connecting it again starts a new setup.
declare function getConnection(userId: string, domain: string): Promise<Connection | undefined>;
declare function saveConnection(userId: string, connection: Connection): Promise<void>;
```

### Entri API calls

Mint the JWT on your server as described in [Token creation (JWT)](https://developers.entri.com/connect/configuration.md#token-creation-jwt). It is valid for 60 minutes, so cache it instead of requesting one per call. `flowStatus` returns `null` when Entri no longer has the flow. Entri keeps a flow for 90 days, so `setupStatus` saves the domain as connected the first time the flow reports it, and both tools keep answering correctly after that.

```ts theme={"system"}
class EntriError extends Error {
  constructor(readonly status: number, message: string) {
    super(message);
  }
}

let cachedToken: { value: string; expiresAt: number } | undefined;

// The JWT is valid for 60 minutes. Reuse it and refresh it a few minutes early.
async function entriToken(): Promise<string> {
  if (cachedToken && Date.now() < cachedToken.expiresAt) return cachedToken.value;
  const res = await fetch(`${ENTRI_API}/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ applicationId: APPLICATION_ID, secret: ENTRI_SECRET }),
  });
  if (!res.ok) throw new EntriError(res.status, "Could not get an Entri token");
  const { auth_token } = (await res.json()) as { auth_token: string };
  cachedToken = { value: auth_token, expiresAt: Date.now() + 55 * 60 * 1000 };
  return auth_token;
}

async function entri<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
  const res = await fetch(`${ENTRI_API}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${await entriToken()}`,
      applicationId: APPLICATION_ID,
      "Content-Type": "application/json",
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new EntriError(res.status, data.message ?? `HTTP ${res.status}`);
  return data as T;
}

type FlowStatus = {
  job_id: string;
  connected: boolean;
  status: "pending" | "in_progress" | "completed" | "failed";
  domain: string;
};

async function createSharingLink(domain: string, dnsRecords: DnsRecord[], userId: string) {
  return entri<{ link: string; job_id: string }>("POST", "/sharing/connect", {
    applicationId: APPLICATION_ID,
    config: {
      prefilledDomain: domain,
      dnsRecords,
      userId,
      manualSetupDocumentation: SETUP_GUIDE_URL,
    },
  });
}

// null when Entri no longer has the flow, for example because the link expired.
async function flowStatus(jobId: string): Promise<FlowStatus | null> {
  try {
    return await entri<FlowStatus>("GET", `/connect/${jobId}/status`);
  } catch (error) {
    if (error instanceof EntriError && error.status === 404) return null;
    throw error;
  }
}

// Entri keeps a flow for 90 days, so remember when the domain connected.
async function setupStatus(userId: string, connection: Connection): Promise<FlowStatus | null> {
  if (connection.connected) {
    return { job_id: connection.jobId, connected: true, status: "completed", domain: connection.domain };
  }
  const status = await flowStatus(connection.jobId);
  if (status?.connected) await saveConnection(userId, { ...connection, connected: true });
  return status;
}
```

The link config sets `userId` so that [webhooks](https://developers.entri.com/webhooks-overview.md) for the flow map to your user, and `manualSetupDocumentation` so that the modal links to your guide if the setup turns out to be manual. It accepts every other [Connect configuration](https://developers.entri.com/connect/configuration.md) option as well, for example `whiteLabel`.

### Result helpers

Both tools return JSON in a text block, which every MCP client can read.

```ts theme={"system"}
// DNS changes take time to propagate. Without this line, some agents call the status tool in a loop.
const CHECK_WHEN_ASKED = "Call get_domain_connection_status again only when the user asks.";

function reply(result: Record<string, unknown>, isError = false) {
  return {
    content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
    isError,
  };
}

// "https://Example.com/shop" -> "example.com". null when it is not a domain.
function normalizeDomain(input: string): string | null {
  const value = input.trim();
  try {
    const { hostname } = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`);
    const domain = hostname.replace(/\.$/, "");
    return domain.includes(".") ? domain : null;
  } catch {
    return null;
  }
}
```

## Create the server

MCP servers can send `instructions` when a client connects, and clients can add them to the agent's context. Use them to describe the workflow across both tools. Not every client uses them, so keep the essentials in the tool descriptions too. If you control the agent's system prompt, for example in an assistant inside your product, add the same text there.

```ts theme={"system"}
export const server = new McpServer(
  { name: "your-platform", version: "1.0.0" },
  {
    instructions: [
      "Custom domains: to connect a domain the user already owns, call connect_domain and follow the next_step in its result.",
      "connect_domain returns a link where the user finishes the setup, either by signing in to their DNS provider or by adding the records by hand.",
      "When the user says they are done, or asks whether their domain works, call get_domain_connection_status.",
      "Never ask the user for DNS provider passwords or verification codes, and never write DNS records yourself.",
    ].join(" "),
  },
);
```

## `connect_domain`

This tool takes the domain and returns a link where the user finishes the setup. A link only works until that setup finishes, so when the tool is called again for the same domain, step 1 returns the stored link only while the setup is unfinished, and no link once the domain is set up.

```ts theme={"system"}
server.registerTool(
  "connect_domain",
  {
    title: "Connect a domain",
    description: [
      "Connect a domain the user already owns to their site by adding DNS records at the user's DNS provider.",
      "Use it when the user wants to connect, point, link or set up a custom domain they own, for example 'connect example.com' or 'use my GoDaddy domain'.",
      "Do not use it to buy or search for a domain.",
      "It returns a link where the user finishes the setup.",
      "Calling it again for the same domain returns the same link while the setup is unfinished, and no link once the domain is set up.",
      "Follow the next_step in the result.",
    ].join(" "),
    inputSchema: {
      domain: z
        .string()
        .describe("The domain the user owns, without protocol or path. Examples: example.com, shop.example.com"),
    },
    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
  },
  async ({ domain: input }, { authInfo }) => {
    const domain = normalizeDomain(input);
    if (!domain) return invalidDomain(input);
    const userId = currentUserId(authInfo);

    try {
      // 1. Same domain again. Entri marks a link as used once the setup on it finishes,
      //    so return the stored link only while the setup is unfinished.
      const existing = await getConnection(userId, domain);
      if (existing) {
        const status = await setupStatus(userId, existing);
        if (status?.connected || status?.status === "completed") {
          // Already set up: no link to send. The status tool reports where it stands.
          return reply({
            domain,
            next_step:
              "This domain is already set up, so there is no link to send. " +
              "Call get_domain_connection_status to tell the user whether it is connected.",
          });
        }
        if (status?.status === "pending" || status?.status === "in_progress") {
          return reply(linkResult(existing));
        }
        // Failed or expired: create a new link below.
      }

      // 2. The records come from your platform, never from the model.
      const dnsRecords = await dnsRecordsFor(userId, domain);

      // 3. Create a sharing link with those records.
      const { link, job_id } = await createSharingLink(domain, dnsRecords, userId);
      const connection: Connection = { domain, link, jobId: job_id, connected: false };
      await saveConnection(userId, connection);
      return reply(linkResult(connection));
    } catch (error) {
      return failure(domain, error);
    }
  },
);
```

The result builder writes the `next_step`, the field in every result that tells the agent what to say to the user and which tool to call next. This one covers both setups the user can get on that page, so the agent is ready when the page asks the user to add the records by hand.

```ts theme={"system"}
function linkResult(connection: Connection) {
  return {
    domain: connection.domain,
    link: connection.link,
    next_step:
      `Send the user this link and ask them to open it: ${connection.link} ` +
      "On that page they either sign in to their DNS provider to approve the DNS changes, or follow the instructions to add the records by hand. " +
      "Never ask for their DNS provider password or verification codes in the chat. " +
      "Ask them to tell you when they finish, then call get_domain_connection_status.",
  };
}
```

## `get_domain_connection_status`

This tool takes the domain, looks up the stored connection, and reads its status with `setupStatus`.

```ts theme={"system"}
server.registerTool(
  "get_domain_connection_status",
  {
    title: "Check a domain connection",
    description: [
      "Check whether a domain set up with connect_domain is connected and working.",
      "Call it when the user says they finished the setup or added the records, or asks whether their domain works yet.",
      "Call it once per user request: DNS changes can take time to propagate, so if the domain is not connected yet, tell the user and wait for them to ask again.",
    ].join(" "),
    inputSchema: {
      domain: z.string().describe("The domain passed to connect_domain, for example example.com"),
    },
    annotations: { readOnlyHint: true, openWorldHint: true },
  },
  async ({ domain: input }, { authInfo }) => {
    const domain = normalizeDomain(input);
    if (!domain) return invalidDomain(input);
    const userId = currentUserId(authInfo);

    try {
      const connection = await getConnection(userId, domain);
      if (!connection) {
        return reply({
          domain,
          connected: false,
          next_step: "No setup was started for this domain. Call connect_domain to start one.",
        });
      }

      const status = await setupStatus(userId, connection);
      if (!status) {
        return reply({
          domain,
          connected: false,
          next_step: "The setup link expired. Call connect_domain to create a new one.",
        });
      }
      return reply({
        domain,
        connected: status.connected,
        status: status.status,
        next_step: statusNextStep(status, connection),
      });
    } catch (error) {
      return failure(domain, error);
    }
  },
);
```

The answer depends on `connected` first. `status` only explains where the user is while the domain is not connected yet.

```ts theme={"system"}
function statusNextStep(status: FlowStatus, connection: Connection): string {
  if (status.connected) return "The domain is connected. Tell the user it is ready.";
  switch (status.status) {
    case "pending":
      return `The user has not started the setup yet. Ask them to open the link: ${connection.link} ${CHECK_WHEN_ASKED}`;
    case "in_progress":
      return (
        "The setup is in progress on the link. Either the records are being applied automatically, or the page asked the user to add them by hand. " +
        `If it did, ask the user to finish adding them and confirm on that page. DNS changes can take time to propagate. ${CHECK_WHEN_ASKED}`
      );
    case "completed":
      return (
        "The setup finished, so the link is no longer needed. " +
        `The DNS changes are propagating, which can take from minutes to hours. Tell the user. ${CHECK_WHEN_ASKED}`
      );
    default:
      return "The setup did not finish. Offer to try again: call connect_domain for this domain to create a new link.";
  }
}
```

Manual setups on Entri's page are tracked the same way. The flow reads `in_progress` while the user adds the records, and `connected` turns `true` once the user confirms on that page and Entri detects the records.

> [!TIP]
>
> If you already receive [Entri webhooks](https://developers.entri.com/webhooks-overview.md), update the stored connection when they arrive and let the status tool read your own record first. The status endpoint then becomes a fallback.

## Handle errors

Return errors as tool results with `isError: true` instead of throwing, so the agent can read the `next_step` and explain the problem to the user. Keep Entri's messages, tokens, and stack traces in your logs and out of the result.

Input that is not a domain never reaches Entri. `normalizeDomain` rejects it, and the tool returns `invalid_domain` so the agent asks the user to check it. Errors from Entri go through `failure`, which logs the details and returns `unavailable`. That covers a link config that uses a feature outside your plan (`400`), invalid credentials (`401` or `403`), and problems on Entri's side (`5xx`). The one exception is a `404` from the flow status endpoint, which `flowStatus` turns into `null` because it means the flow expired.

```ts theme={"system"}
function invalidDomain(domain: string) {
  return reply(
    {
      domain,
      error: "invalid_domain",
      next_step: "This is not a valid domain. Ask the user to check it, for example example.com.",
    },
    true,
  );
}

function failure(domain: string, error: unknown) {
  console.error(`Domain setup failed for ${domain}`, error); // details stay in your logs
  return reply(
    {
      domain,
      error: "unavailable",
      next_step: "Domain setup is not available right now. Tell the user to try again later. Do not retry on your own.",
    },
    true,
  );
}
```

## Test tool selection

Before you ship, run real requests through the agents your users have and check three things for each one: which tool the agent calls, the domain it passes, and what it tells the user. Keep the set and run it again whenever you change a tool name, a description, or a `next_step`, since a small wording change can shift which tool the agent picks.

| The user says | Expected behavior |
| - | - |
| "Connect example.com to my site" | Calls `connect_domain` with `example.com`. |
| "I want to use a domain I already have" | Asks for the domain, then calls `connect_domain`. |
| "Done, I added the records" | Calls `get_domain_connection_status` once. |
| "Is my domain working yet?" in a new conversation | Asks for the domain if it does not know it, then calls `get_domain_connection_status` without starting a new setup. |
| "The link doesn't work anymore" | Calls `connect_domain`. If the domain is already set up, calls `get_domain_connection_status` instead of sending a link. |
| "Find me a domain name for my bakery" | Calls neither tool. |

Also check what the agent says: it shares the link, and it never asks for the DNS provider password. The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) (`npx @modelcontextprotocol/inspector`) lets you check the tool list and call each tool directly before you test with an agent.

## Full example

> [accordion] server.ts
>
> ```ts theme={"system"}
> import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
> import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
> import { z } from "zod";
>
> // ---------------------------------------------------------------------------
> // Settings
> // ---------------------------------------------------------------------------
>
> const ENTRI_API = "https://api.goentri.com";
> const APPLICATION_ID = process.env.ENTRI_APPLICATION_ID!;
> const ENTRI_SECRET = process.env.ENTRI_SECRET!;
> // Optional: your own guide for adding DNS records by hand. The modal links to it.
> const SETUP_GUIDE_URL = "https://help.example.com/connect-your-domain";
>
> // ---------------------------------------------------------------------------
> // Your platform: implement these with your own code
> // ---------------------------------------------------------------------------
>
> type DnsRecord = { type: string; host: string; value: string; ttl: number; priority?: number };
>
> type Connection = { domain: string; link: string; jobId: string; connected: boolean };
>
> // The user behind the MCP request, from your own MCP authentication.
> declare function currentUserId(authInfo?: AuthInfo): string;
> // The records this user's site needs, in Entri's dnsRecords format.
> declare function dnsRecordsFor(userId: string, domain: string): Promise<DnsRecord[]>;
> // One connection per user and domain, stored in your database.
> // Delete it when the user removes the domain, so that connecting it again starts a new setup.
> declare function getConnection(userId: string, domain: string): Promise<Connection | undefined>;
> declare function saveConnection(userId: string, connection: Connection): Promise<void>;
>
> // ---------------------------------------------------------------------------
> // Entri API
> // ---------------------------------------------------------------------------
>
> class EntriError extends Error {
>   constructor(readonly status: number, message: string) {
>     super(message);
>   }
> }
>
> let cachedToken: { value: string; expiresAt: number } | undefined;
>
> // The JWT is valid for 60 minutes. Reuse it and refresh it a few minutes early.
> async function entriToken(): Promise<string> {
>   if (cachedToken && Date.now() < cachedToken.expiresAt) return cachedToken.value;
>   const res = await fetch(`${ENTRI_API}/token`, {
>     method: "POST",
>     headers: { "Content-Type": "application/json" },
>     body: JSON.stringify({ applicationId: APPLICATION_ID, secret: ENTRI_SECRET }),
>   });
>   if (!res.ok) throw new EntriError(res.status, "Could not get an Entri token");
>   const { auth_token } = (await res.json()) as { auth_token: string };
>   cachedToken = { value: auth_token, expiresAt: Date.now() + 55 * 60 * 1000 };
>   return auth_token;
> }
>
> async function entri<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
>   const res = await fetch(`${ENTRI_API}${path}`, {
>     method,
>     headers: {
>       Authorization: `Bearer ${await entriToken()}`,
>       applicationId: APPLICATION_ID,
>       "Content-Type": "application/json",
>     },
>     body: body === undefined ? undefined : JSON.stringify(body),
>   });
>   const data = await res.json().catch(() => ({}));
>   if (!res.ok) throw new EntriError(res.status, data.message ?? `HTTP ${res.status}`);
>   return data as T;
> }
>
> type FlowStatus = {
>   job_id: string;
>   connected: boolean;
>   status: "pending" | "in_progress" | "completed" | "failed";
>   domain: string;
> };
>
> async function createSharingLink(domain: string, dnsRecords: DnsRecord[], userId: string) {
>   return entri<{ link: string; job_id: string }>("POST", "/sharing/connect", {
>     applicationId: APPLICATION_ID,
>     config: {
>       prefilledDomain: domain,
>       dnsRecords,
>       userId,
>       manualSetupDocumentation: SETUP_GUIDE_URL,
>     },
>   });
> }
>
> // null when Entri no longer has the flow, for example because the link expired.
> async function flowStatus(jobId: string): Promise<FlowStatus | null> {
>   try {
>     return await entri<FlowStatus>("GET", `/connect/${jobId}/status`);
>   } catch (error) {
>     if (error instanceof EntriError && error.status === 404) return null;
>     throw error;
>   }
> }
>
> // Entri keeps a flow for 90 days, so remember when the domain connected.
> async function setupStatus(userId: string, connection: Connection): Promise<FlowStatus | null> {
>   if (connection.connected) {
>     return { job_id: connection.jobId, connected: true, status: "completed", domain: connection.domain };
>   }
>   const status = await flowStatus(connection.jobId);
>   if (status?.connected) await saveConnection(userId, { ...connection, connected: true });
>   return status;
> }
>
> // ---------------------------------------------------------------------------
> // Tool results
> // ---------------------------------------------------------------------------
>
> // DNS changes take time to propagate. Without this line, some agents call the status tool in a loop.
> const CHECK_WHEN_ASKED = "Call get_domain_connection_status again only when the user asks.";
>
> function reply(result: Record<string, unknown>, isError = false) {
>   return {
>     content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
>     isError,
>   };
> }
>
> // "https://Example.com/shop" -> "example.com". null when it is not a domain.
> function normalizeDomain(input: string): string | null {
>   const value = input.trim();
>   try {
>     const { hostname } = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`);
>     const domain = hostname.replace(/\.$/, "");
>     return domain.includes(".") ? domain : null;
>   } catch {
>     return null;
>   }
> }
>
> function linkResult(connection: Connection) {
>   return {
>     domain: connection.domain,
>     link: connection.link,
>     next_step:
>       `Send the user this link and ask them to open it: ${connection.link} ` +
>       "On that page they either sign in to their DNS provider to approve the DNS changes, or follow the instructions to add the records by hand. " +
>       "Never ask for their DNS provider password or verification codes in the chat. " +
>       "Ask them to tell you when they finish, then call get_domain_connection_status.",
>   };
> }
>
> function statusNextStep(status: FlowStatus, connection: Connection): string {
>   if (status.connected) return "The domain is connected. Tell the user it is ready.";
>   switch (status.status) {
>     case "pending":
>       return `The user has not started the setup yet. Ask them to open the link: ${connection.link} ${CHECK_WHEN_ASKED}`;
>     case "in_progress":
>       return (
>         "The setup is in progress on the link. Either the records are being applied automatically, or the page asked the user to add them by hand. " +
>         `If it did, ask the user to finish adding them and confirm on that page. DNS changes can take time to propagate. ${CHECK_WHEN_ASKED}`
>       );
>     case "completed":
>       return (
>         "The setup finished, so the link is no longer needed. " +
>         `The DNS changes are propagating, which can take from minutes to hours. Tell the user. ${CHECK_WHEN_ASKED}`
>       );
>     default:
>       return "The setup did not finish. Offer to try again: call connect_domain for this domain to create a new link.";
>   }
> }
>
> function invalidDomain(domain: string) {
>   return reply(
>     {
>       domain,
>       error: "invalid_domain",
>       next_step: "This is not a valid domain. Ask the user to check it, for example example.com.",
>     },
>     true,
>   );
> }
>
> function failure(domain: string, error: unknown) {
>   console.error(`Domain setup failed for ${domain}`, error); // details stay in your logs
>   return reply(
>     {
>       domain,
>       error: "unavailable",
>       next_step: "Domain setup is not available right now. Tell the user to try again later. Do not retry on your own.",
>     },
>     true,
>   );
> }
>
> // ---------------------------------------------------------------------------
> // Server and tools
> // ---------------------------------------------------------------------------
>
> export const server = new McpServer(
>   { name: "your-platform", version: "1.0.0" },
>   {
>     instructions: [
>       "Custom domains: to connect a domain the user already owns, call connect_domain and follow the next_step in its result.",
>       "connect_domain returns a link where the user finishes the setup, either by signing in to their DNS provider or by adding the records by hand.",
>       "When the user says they are done, or asks whether their domain works, call get_domain_connection_status.",
>       "Never ask the user for DNS provider passwords or verification codes, and never write DNS records yourself.",
>     ].join(" "),
>   },
> );
>
> server.registerTool(
>   "connect_domain",
>   {
>     title: "Connect a domain",
>     description: [
>       "Connect a domain the user already owns to their site by adding DNS records at the user's DNS provider.",
>       "Use it when the user wants to connect, point, link or set up a custom domain they own, for example 'connect example.com' or 'use my GoDaddy domain'.",
>       "Do not use it to buy or search for a domain.",
>       "It returns a link where the user finishes the setup.",
>       "Calling it again for the same domain returns the same link while the setup is unfinished, and no link once the domain is set up.",
>       "Follow the next_step in the result.",
>     ].join(" "),
>     inputSchema: {
>       domain: z
>         .string()
>         .describe("The domain the user owns, without protocol or path. Examples: example.com, shop.example.com"),
>     },
>     annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
>   },
>   async ({ domain: input }, { authInfo }) => {
>     const domain = normalizeDomain(input);
>     if (!domain) return invalidDomain(input);
>     const userId = currentUserId(authInfo);
>
>     try {
>       // 1. Same domain again. Entri marks a link as used once the setup on it finishes,
>       //    so return the stored link only while the setup is unfinished.
>       const existing = await getConnection(userId, domain);
>       if (existing) {
>         const status = await setupStatus(userId, existing);
>         if (status?.connected || status?.status === "completed") {
>           // Already set up: no link to send. The status tool reports where it stands.
>           return reply({
>             domain,
>             next_step:
>               "This domain is already set up, so there is no link to send. " +
>               "Call get_domain_connection_status to tell the user whether it is connected.",
>           });
>         }
>         if (status?.status === "pending" || status?.status === "in_progress") {
>           return reply(linkResult(existing));
>         }
>         // Failed or expired: create a new link below.
>       }
>
>       // 2. The records come from your platform, never from the model.
>       const dnsRecords = await dnsRecordsFor(userId, domain);
>
>       // 3. Create a sharing link with those records.
>       const { link, job_id } = await createSharingLink(domain, dnsRecords, userId);
>       const connection: Connection = { domain, link, jobId: job_id, connected: false };
>       await saveConnection(userId, connection);
>       return reply(linkResult(connection));
>     } catch (error) {
>       return failure(domain, error);
>     }
>   },
> );
>
> server.registerTool(
>   "get_domain_connection_status",
>   {
>     title: "Check a domain connection",
>     description: [
>       "Check whether a domain set up with connect_domain is connected and working.",
>       "Call it when the user says they finished the setup or added the records, or asks whether their domain works yet.",
>       "Call it once per user request: DNS changes can take time to propagate, so if the domain is not connected yet, tell the user and wait for them to ask again.",
>     ].join(" "),
>     inputSchema: {
>       domain: z.string().describe("The domain passed to connect_domain, for example example.com"),
>     },
>     annotations: { readOnlyHint: true, openWorldHint: true },
>   },
>   async ({ domain: input }, { authInfo }) => {
>     const domain = normalizeDomain(input);
>     if (!domain) return invalidDomain(input);
>     const userId = currentUserId(authInfo);
>
>     try {
>       const connection = await getConnection(userId, domain);
>       if (!connection) {
>         return reply({
>           domain,
>           connected: false,
>           next_step: "No setup was started for this domain. Call connect_domain to start one.",
>         });
>       }
>
>       const status = await setupStatus(userId, connection);
>       if (!status) {
>         return reply({
>           domain,
>           connected: false,
>           next_step: "The setup link expired. Call connect_domain to create a new one.",
>         });
>       }
>       return reply({
>         domain,
>         connected: status.connected,
>         status: status.status,
>         next_step: statusNextStep(status, connection),
>       });
>     } catch (error) {
>       return failure(domain, error);
>     }
>   },
> );
> ```

Connect `server` to your transport the same way you do for your other tools.

## Handle manual setups yourself (optional)

You do not need this section to connect domains. When Entri cannot configure a DNS provider automatically, its page already shows the user the records to add by hand and links to your `manualSetupDocumentation`. Add the steps below only if you want the agent to walk those users through your own guide in the chat instead, without sending a link.

To do that, call [`POST /checkdomain`](https://developers.entri.com/connect/sdk#http-endpoint) before you create the link. It returns the DNS provider and whether the setup will be `Automatic` or `Manual`. When it is `Manual`, `connect_domain` returns the records and your guide instead of a link, and the status tool checks those records with your own DNS check, since no Entri flow runs.

`/checkdomain` is available on the Premium tier and above. It predicts the setup from the same provider data the modal uses, but the modal makes the final decision when the user opens a link. Pass it the same records you put in the link, because it also returns `Manual` when the provider cannot host one of them, for example an NS record on a subdomain.

When it cannot detect the provider, `/checkdomain` returns `Manual` with `provider: "Unknown"`. The modal would let those users choose their provider from a list, and some of those providers support automatic setup. If you would rather give them that chance, also create the link when `provider` is `"Unknown"`.

Add these next to the shared code:

```ts theme={"system"}
type CheckDomainResult = { provider: string; setupType: "Automatic" | "Manual" };

// Your platform: remember which domains use a manual setup.
declare function saveManualSetup(userId: string, domain: string): Promise<void>;
declare function hasManualSetup(userId: string, domain: string): Promise<boolean>;
// The check your platform runs before it serves a custom domain.
declare function recordsAreLive(userId: string, domain: string): Promise<boolean>;

// null when Entri cannot check the domain, for example because /checkdomain is not on your plan.
// connect_domain then creates the link, and the modal decides as it does without the check.
async function checkDomain(domain: string, dnsRecords: DnsRecord[]): Promise<CheckDomainResult | null> {
  try {
    return await entri<CheckDomainResult>("POST", "/checkdomain", { domain, dnsRecords });
  } catch (error) {
    if (error instanceof EntriError && error.status === 400) return null;
    throw error;
  }
}

function manualResult(domain: string, provider: string, records: DnsRecord[]) {
  const where = provider === "Unknown" ? "their DNS provider" : provider;
  return {
    domain,
    setup: "manual",
    records,
    guide: SETUP_GUIDE_URL,
    next_step:
      `This domain needs a manual setup: the user adds these records at ${where}. There is no setup link for it. ` +
      "Show the records as a table (type, host, value, TTL, and priority for MX) exactly as returned. " +
      `Share the guide: ${SETUP_GUIDE_URL} ` +
      "Ask the user to tell you when the records are added, then call get_domain_connection_status.",
  };
}
```

In `connect_domain`, check the domain after loading the records and before creating the link:

```ts theme={"system"}
// Manual setup: no link. Return the records and your own guide.
const check = await checkDomain(domain, dnsRecords);
if (check?.setupType === "Manual") {
  await saveManualSetup(userId, domain);
  return reply(manualResult(domain, check.provider, dnsRecords));
}
```

In `get_domain_connection_status`, check manual setups first, at the start of the `try` block:

```ts theme={"system"}
// Manual setups have no Entri flow, so check their records yourself.
if (await hasManualSetup(userId, domain)) {
  const connected = await recordsAreLive(userId, domain);
  return reply({
    domain,
    setup: "manual",
    connected,
    next_step: connected
      ? "The domain is connected. Tell the user it is ready."
      : "The records are not visible yet. Ask the user to compare what they added with the records from connect_domain. " +
        `DNS changes can take time to propagate. ${CHECK_WHEN_ASKED}`,
  });
}
```

Last, replace the sentence about the result in the server instructions and in the `connect_domain` description, so the agent expects either result:

```ts theme={"system"}
// In the server instructions:
"connect_domain returns either a link where the user finishes the setup, or DNS records the user adds by hand with our guide.",

// In the connect_domain description:
"It returns either a link where the user finishes the setup, or DNS records the user adds by hand with our guide.",
```

When you test, also check that for a manual setup the agent shows the records as a table with your guide and does not offer a link.
