# Build a client in TypeScript

> A complete Zorlya MCP client in about 60 lines of Node with no dependencies, covering calls, cursor paging and a token refresh that keeps the rotated token.

Source: https://zorlya.com/guides/developers/build-a-client-in-typescript

Updated 2026-10-25.

A working Zorlya MCP client fits in about 60 lines of Node with no dependencies. This guide builds one that signs requests with a token you already have, calls tools, follows cursors and refreshes an expired token. It uses only `fetch`, so it runs on Node 18 or newer.

You need three values from [Sign in with OAuth by hand](/guides/developers/sign-in-with-oauth-by-hand): your `client_id`, an access token and a refresh token. Export them:

```bash
export ZORLYA_CLIENT_ID=…
export ZORLYA_ACCESS_TOKEN=…
export ZORLYA_REFRESH_TOKEN=…
```

## The client

Save this as `client.mjs`:

```js
// A minimal Zorlya MCP client. Node 18 or newer, no dependencies.
// Two hosts: the authorization server (token endpoint) and the MCP server.
const ISSUER = process.env.ZORLYA_API ?? 'https://api.zorlya.com';
const MCP = process.env.ZORLYA_MCP ?? 'https://mcp.zorlya.com';
const CLIENT_ID = process.env.ZORLYA_CLIENT_ID;
let accessToken = process.env.ZORLYA_ACCESS_TOKEN;
let refreshToken = process.env.ZORLYA_REFRESH_TOKEN;
let nextId = 1;

async function refresh() {
  const res = await fetch(`${ISSUER}/oauth/token`, {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID }),
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`refresh failed: ${body.error}: ${body.error_description}`);
  // The refresh token rotates: the old one is dead the moment this answers, so keep the new one.
  accessToken = body.access_token;
  refreshToken = body.refresh_token;
}

async function rpc(method, params, retried = false) {
  const res = await fetch(MCP, {
    method: 'POST',
    headers: { 'content-type': 'application/json', authorization: `Bearer ${accessToken}` },
    body: JSON.stringify({ jsonrpc: '2.0', id: nextId++, method, params }),
  });
  if (res.status === 401 && !retried) {
    await refresh();
    return rpc(method, params, true);
  }
  const message = await res.json();
  if (message.error) throw new Error(`${method}: ${message.error.code} ${message.error.message}`);
  return message.result;
}

/** Calls a tool and returns its JSON result. A tool failure comes back as a result with isError, not as a JSON-RPC error. */
async function callTool(name, args = {}) {
  const result = await rpc('tools/call', { name, arguments: args });
  const text = result.content?.[0]?.text ?? '';
  if (result.isError) throw new Error(text);
  return JSON.parse(text);
}

/** Follows next_cursor until has_more is false. */
async function allPages(name, args, key) {
  const items = [];
  let cursor = null;
  do {
    const page = await callTool(name, { ...args, ...(cursor ? { cursor } : {}) });
    items.push(...page[key]);
    cursor = page.has_more ? page.next_cursor : null;
  } while (cursor);
  return items;
}

await rpc('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'my-client', version: '0.1.0' } });
const top = await allPages('top_matches', { limit: 20 }, 'matches');
console.log(`${top.length} matches; best: ${top[0]?.title} at ${top[0]?.company} (${top[0]?.score})`);
```

Run it with `node client.mjs`. It prints the number of matches and the best one.

## How it works

- **`rpc`** sends one JSON-RPC message with the bearer token. On a `401` it refreshes once and retries. A second `401` throws, which means the person has to sign in again.
- **`refresh`** trades the refresh token for a new pair. The old refresh token stops working the moment this succeeds, so the code stores the new one. If you keep tokens on disk or in a database, write the new pair before you use it, or a crash leaves you holding a dead refresh token.
- **`callTool`** parses the text the tool returned. A failed tool arrives as a normal result with `isError` set, not as a JSON-RPC error, so the function checks it and throws.
- **`allPages`** follows `next_cursor` while `has_more` is true, as [Page results and handle errors](/guides/developers/page-results-and-handle-errors) describes.

## Make it your own

- Point `ZORLYA_API` and `ZORLYA_MCP` at other hosts to test against a local copy. The MCP address is the root of `mcp.zorlya.com`, with no path. The token endpoint is on `api.zorlya.com`.
- Call `rpc('tools/list')` first and read each `inputSchema` if you want to validate arguments before sending them.
- Keep one client instance per person. Tokens belong to one account.
- Add a pause or a queue if you call in bursts: the limits are 120 reads, 30 writes and 5 deletes a minute for the account.

## What we checked

The first version of this file was run on Node 24 against a local copy of the server on 2026-10-17. On 2026-10-18 it was changed to use the two hosts, and the new version was checked for syntax only. It was not run against the production server.
