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: your client_id, an access token and a refresh token. Export them:
export ZORLYA_CLIENT_ID=…
export ZORLYA_ACCESS_TOKEN=…
export ZORLYA_REFRESH_TOKEN=…
The client
Save this as client.mjs:
// 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
rpcsends one JSON-RPC message with the bearer token. On a401it refreshes once and retries. A second401throws, which means the person has to sign in again.refreshtrades 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.callToolparses the text the tool returned. A failed tool arrives as a normal result withisErrorset, not as a JSON-RPC error, so the function checks it and throws.allPagesfollowsnext_cursorwhilehas_moreis true, as Page results and handle errors describes.
Make it your own
- Point
ZORLYA_APIandZORLYA_MCPat other hosts to test against a local copy. The MCP address is the root ofmcp.zorlya.com, with no path. The token endpoint is onapi.zorlya.com. - Call
rpc('tools/list')first and read eachinputSchemaif 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.