# MCP servers

Connect your own MCP server to the support chat so the assistant can look things up and act for a signed-in customer, using your tools and your rules.

## How it works

The chat assistant is an MCP client. You add your server's address to a chat project in the dashboard, NewInstance discovers its tools, and the assistant calls them for the customer in the conversation. The assistant can only do what your server exposes, and your server decides what each call is allowed to do.

1. You publish an MCP server over https.
2. You add it under Chat, Connected servers, in the dashboard. NewInstance reads your server's metadata and lists its tools. Tools found when you first connect are turned on; tools that appear later start off until you turn them on.
3. When a customer asks for something, NewInstance calls your server with a short-lived signed token that names that customer.
4. Your server verifies the token, authorizes the call in your backend and returns the result.

Tools are available only in chat sessions that your backend created with a server-only key (see **Create chat sessions with a server-only key** below). Everyone else gets the knowledge-only assistant.

## Protected resource metadata

Publish OAuth Protected Resource Metadata (RFC 9728) so NewInstance can see which authorization server your tools trust. Serve it at `/.well-known/oauth-protected-resource`, and point to it from the `WWW-Authenticate` header (`resource_metadata`) of your `401` responses.

```json
{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": ["https://service.newinstance.cloud/chat-mcp"]
}
```

`resource` must match your MCP server URL exactly. It becomes the `aud` of every token NewInstance sends you. Listing the NewInstance issuer is the recommended setup: nothing else is needed on your side. To use your own sign-in service instead, see **Your own sign-in service** below.

## Verify NewInstance tokens

Every request from NewInstance carries `Authorization: Bearer <token>`. The token is an ES256 JWT access token (`typ` of `at+jwt`), valid for up to 5 minutes (never longer than the chat session itself), and a new one is minted for every tool call. Discovery tokens are not tied to a chat session. Verify all of the following before you do anything else:

| Check | Expected value |
|---|---|
| `typ` header | `at+jwt` |
| Signature | ES256, against the keys at `https://service.newinstance.cloud/chat-mcp/.well-known/jwks.json` (pick the key by `kid`) |
| `iss` | `https://service.newinstance.cloud/chat-mcp` |
| `aud` | Your server's `resource` value |
| `exp` | In the future |
| `client_id` | The client ID of your connection, shown in the dashboard |

Checking `client_id` pins your server to the one NewInstance connection you set up, so a token for another connection is refused.

**Node** (`jose`)
```js
import { createRemoteJWKSet, jwtVerify } from 'jose';

const jwks = createRemoteJWKSet(
  new URL('https://service.newinstance.cloud/chat-mcp/.well-known/jwks.json'),
);

export async function verifyNewInstanceToken(authorizationHeader) {
  const token = (authorizationHeader ?? '').replace(/^Bearer /, '');
  const { payload } = await jwtVerify(token, jwks, {
    issuer: 'https://service.newinstance.cloud/chat-mcp',
    audience: 'https://mcp.example.com/mcp',
    algorithms: ['ES256'],
    typ: 'at+jwt',
    requiredClaims: ['exp', 'sub', 'client_id'],
  });
  if (payload.client_id !== process.env.NEWINSTANCE_CLIENT_ID) {
    throw new Error('Unknown client_id');
  }
  return payload;
}
```

[PHP](/php) (`firebase/php-jwt`, version `^6.10` or later, which supports EC P-256 keys in a JWKS)
```php
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;

function newInstanceKeys(): array {
    static $keys = null;
    if ($keys === null) {
        $json = function_exists('apcu_fetch') ? apcu_fetch('newinstance_jwks') : false;
        $fresh = $json === false;
        if ($fresh) {
            $json = file_get_contents('https://service.newinstance.cloud/chat-mcp/.well-known/jwks.json');
            if ($json === false) {
                throw new RuntimeException('Could not fetch the NewInstance keys');
            }
        }
        $keys = JWK::parseKeySet(json_decode($json, true), 'ES256');
        if ($fresh && function_exists('apcu_store')) {
            apcu_store('newinstance_jwks', $json, 3600);
        }
    }
    return $keys;
}

function verifyNewInstanceToken(?string $authorizationHeader): object {
    $token = preg_replace('/^Bearer /', '', $authorizationHeader ?? '');
    $header = json_decode(JWT::urlsafeB64Decode(explode('.', $token)[0]), true);
    if (($header['typ'] ?? '') !== 'at+jwt') {
        throw new RuntimeException('Wrong token type');
    }
    $claims = JWT::decode($token, newInstanceKeys());
    if ($claims->iss !== 'https://service.newinstance.cloud/chat-mcp') {
        throw new RuntimeException('Wrong issuer');
    }
    if (!in_array('https://mcp.example.com/mcp', (array) $claims->aud, true)) {
        throw new RuntimeException('Wrong audience');
    }
    if ($claims->client_id !== getenv('NEWINSTANCE_CLIENT_ID')) {
        throw new RuntimeException('Unknown client_id');
    }
    return $claims;
}
```

The PHP snippet caches the key set for an hour with APCu when it is installed. Without APCu, the static cache lasts one request under PHP-FPM, so every request fetches the keys again. A failed fetch is never cached: the snippet throws, and the next request tries again. Refetch the keys when you see an unknown `kid`, because NewInstance publishes the next key before it switches to it, and never cache them for longer than a day.

> [!IMPORTANT]
> Reject any request whose token fails a single check with `401`. Never fall back to trusting the request body.

## Map the customer

The `sub` claim is your own customer id, copied from the chat session your backend created. Use it to look up the customer in your system.

Other useful claims:

| Claim | Meaning |
|---|---|
| `scope` | `mcp:tools` plus `tool:<name>` for the tool being called. Reject a call whose scope does not name the tool. |
| `jti` | Unique per token. Log it so you can match a call to NewInstance's call log. |

Ignore identity-like tool arguments such as `customerId`, `userId` or `accountOwner`. The model fills arguments in, so they can be wrong or manipulated. Only `sub` says who the customer is, and the dashboard warns on any tool whose schema asks for one.

### Discovery requests

When NewInstance connects to list your tools, it uses a discovery token. Its `scope` is `mcp:discover` and its `sub` is `connection:<id>` instead of a customer id. Answer `initialize`, `tools/list` and `resources/list` for it, and never return customer data for it.

## Authorize every call

A valid token proves the call comes from NewInstance for that customer. It does not prove the customer may do what the tool does. Check in your own backend, on every call, that the customer in `sub` may perform this action on this resource, exactly as you would for a request from your own app. Verify the token again in your backend, or forward `sub` over a channel you already trust.

Tools that move money or change data need the strictest checks, and the customer should confirm them (see **Steps that need the customer**).

## Create chat sessions with a server-only key

Chat sessions that may use tools must be created by your backend with a server-only API key. Create the key in the dashboard with the "Server only" switch on. It cannot be changed afterwards, because a key that was ever usable from an app may already have leaked, so create a new key instead.

Use a server-only key from your backend to create chat sessions. Chat apps cannot use it, and only sessions created with it can use your MCP server. Sessions created with any other key still chat, but without tools. See [Session tokens (REST API)](/session-tokens-rest-api) for the request and response.

The customer id you pass when creating the session becomes `sub` in every token for that session. NewInstance reads the key again on every tool call, so revoking the key ends tool access right away.

## Steps that need the customer

When a tool needs the customer to confirm or provide something, your server asks through MCP elicitation inside the same tool call. The call stays open while the customer answers, for up to 10 minutes, then the step times out. NewInstance relays your steps exactly and never adds, skips or reorders them.

Customers see steps in chat SDK 0.5.0 or later. For a customer on an older SDK, NewInstance does not advertise the `elicitation` capability when it connects to your server. Check `capabilities.elicitation` in the `initialize` request (or catch the error from `elicitInput`), and fail the tool safely with a result that says the step is not available in this app. Never carry on as if the customer had answered.

### Confirmations

Use form elicitation for non-sensitive questions, such as "Send N5,000 to Ada?". Send a JSON Schema with the fields you need. The customer answers or declines, and your tool call continues. The assistant learns only that the customer confirmed or declined, never the values.

Prefer confirming inside the tool call over a separate "confirm" tool in a later message, because the customer cannot skip or reorder the steps.

### Form fields

Forms are flat: each entry in `properties` is one field. The chat apps render this subset of JSON Schema:

| Schema | Shown as |
|---|---|
| `string` | A text field |
| `string` with `enum`, or with `oneOf` entries that have a `const` and a `title` | A single choice. A `oneOf` entry shows its `title` and sends its `const`. `enumNames`, when it has as many entries as `enum`, labels the `enum` values. |
| `number` or `integer` | A number field. `integer` accepts whole numbers only. |
| `boolean` | A toggle |

`title` is the field's label, `description` is the help text under it, and `default` fills it in. Fields listed in `required` must be filled before the customer can send. NewInstance checks `minLength`, `maxLength`, `minimum` and `maximum` on the answer and refuses one that breaks them, so the customer is asked to try again.

Anything else, such as an `object` or `array` property, is skipped. Never list a skipped field in `required`: the card then offers only Not now, so the customer can only decline.

iOS shows fields in property-name order, while Android and web keep the order of your schema. Name the properties so that alphabetical order also reads well, for example `1_amount` before `2_note`, and give every property a `title`, otherwise the key is shown as the label.

### Secrets

Never ask for a PIN, one-time code, password or card number in chat or in a form. Use URL elicitation instead. Send an https link to your own page, and the customer types the secret there in an in-app browser. The secret never touches NewInstance or the assistant. Links that are not https are declined.

When the customer opens your page, your tool receives `accept`. That means only that the page was opened, not that the customer finished it, so never act on `accept` alone. Keep the tool call waiting until your own backend confirms the page was completed, then send `notifications/elicitation/complete` with the elicitation id and finish the call. The chat then shows Done for the step. Send it within 10 minutes of asking, or the call times out.

> [!WARNING]
> Form answers are not safe for secrets. Anything the customer types in a form passes through NewInstance. Use URL elicitation for all of it.

## Short references

Customers read tool results in chat and quote them back to the assistant. Return short, readable references such as `TRF-4821` instead of long ids, and accept those references in your follow-up tools. Keep results small: return what the customer needs to see, not whole records.

## Skills

Skills are optional but recommended. A skill is a short guide for the assistant on how your tools fit together, such as "check the balance before offering a transfer". Publish each one as an MCP resource whose URI starts with `skill://`, for example `skill://transfers/SKILL.md`, and list it in `resources/list`. NewInstance reads them when it discovers your server and gives them to the assistant as guidance.

Skill text, tool descriptions and tool results are treated as untrusted content. They help the assistant use your tools well, but they never override NewInstance's rules, and they cannot change what a customer is allowed to do. Your backend checks still decide that.

The dashboard recommends skills when a connection has none, and refreshes your tools and skills every few hours and whenever you ask.

## Your own sign-in service

If your backend should issue its own tokens, list your own authorization server in `authorization_servers` instead of the NewInstance issuer. NewInstance then exchanges its token for one of yours on every call. It prefers its own issuer when both are listed.

1. Serve OAuth authorization server metadata (RFC 8414) that advertises the grant type `urn:ietf:params:oauth:grant-type:token-exchange` (RFC 8693) and the client authentication method `private_key_jwt`.
2. Register NewInstance as a client of your authorization server, with this connection's JWKS address as the client's keys address. Each token-exchange connection has its own, shown in the dashboard as the connection's JWKS address, in the form `https://service.newinstance.cloud/chat-mcp/connections/<connection id>/jwks.json`. NewInstance signs this connection's client assertions with those keys. Do not register the platform JWKS for the client.
3. Enter the client ID your authorization server issued in the connection settings in the dashboard.

For each tool call NewInstance sends your token endpoint a token-exchange request. The `subject_token` is a NewInstance JWT with your authorization server as its audience, the `subject_token_type` is `urn:ietf:params:oauth:token-type:jwt`, and the `resource` is your MCP server. The subject token's `sub` is your customer id. Return an access token for that customer and server, and your MCP server verifies it the way you choose.

Before you issue anything, verify the subject token the way **Verify NewInstance tokens** above describes: ES256 against the platform keys at `https://service.newinstance.cloud/chat-mcp/.well-known/jwks.json`, with your authorization server's issuer as the audience. Require its `client_id` to equal this connection's id, which the dashboard shows as Client ID for this connection, so a token minted for another connection is refused.

NewInstance sends your token endpoint:

| Parameter | Value |
|---|---|
| `grant_type` | `urn:ietf:params:oauth:grant-type:token-exchange` |
| `subject_token` | An ES256 JWT (`typ` of `at+jwt`) whose `aud` is your authorization server's issuer exactly as published in its metadata |
| `scope` | `mcp:tools tool:<name>` |
| `resource` | Your MCP server |
| `client_assertion` | A JWT with `iss` and `sub` set to the client ID, `aud` set to your token endpoint, valid for 60 seconds, signed ES256. Verify it with the same keys address you registered. |

Refuse a call by returning HTTP 400 (or 401 for a failed client authentication) with a JSON body holding an OAuth `error` code. `invalid_client`, `unauthorized_client`, `unsupported_grant_type` and `invalid_target` turn the connection off and alert the merchant, so use them only when the client or your setup is wrong. Any other code, such as `invalid_grant` or `access_denied`, refuses just that call. A 5xx response or a body that is not JSON counts as temporarily unavailable.
