← Back to Blog
TutorialReading time: 12 minutes

Building a Custom MCP Client for ServiceNow Workflows

How to write a minimal but production-shaped MCP client against SnowCoder, with OAuth 2.1, PKCE, scoped tokens, and Yeti Build Agent calls. The code is JavaScript; the patterns apply to any language.

SnowCoder Team
June 2, 2026

Why You Might Build a Custom Client

SnowCoder MCP supports a wide client roster out of the box: Claude Code, Claude Desktop, Cursor, Continue, GCP Vertex AI, ChatGPT Desktop, OpenAI Codex, and Grok. For most developers, picking one of those is the right answer. A custom client makes sense when you have an automation surface that does not look like a chat window: a CI runner, a scheduled job, a Slack bot, a backstage operator console, or an internal portal.

Custom clients also work well for unattended workflows. The Yeti Build Agent is an Enterprise capability and MCP is on every tier, so a service account with the right scopes can drive nightly builds without a human in the loop. The shape of the client is the same; the consent step is the only piece that differs.

This post walks through a minimal client in Node.js. It handles auth, calls a tool, and inspects per-AC diffs. The structure is small on purpose. Everything you would add for production (retries, observability, secret storage) layers on cleanly.

Step 1: OAuth 2.1 with PKCE

SnowCoder MCP uses OAuth 2.1 with PKCE (S256). The verifier is generated by your client and never leaves it; the challenge is the SHA-256 hash. Here is the minimum implementation:

import crypto from 'node:crypto';

function pkcePair() {
  const verifier = crypto.randomBytes(48).toString('base64url');
  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');
  return { verifier, challenge };
}

Store the verifier alongside the state parameter you pass to the authorize endpoint. The token exchange step needs it, and you must not leak it to a redirect URL.

Step 2: Dynamic Client Registration

SnowCoder MCP supports RFC 7591 Dynamic Client Registration. Your client posts metadata and receives a client identifier. There is no admin step to mint credentials by hand.

async function registerClient() {
  const res = await fetch('https://yeti-api.snowcoder.ai/oauth/register', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      client_name: 'internal-build-runner',
      redirect_uris: ['http://127.0.0.1:51777/callback'],
      scope: [
        'mcp:read', 'mcp:write',
        'kb:read',
        'projects:read', 'projects:write',
        'builds:read', 'builds:write'
      ].join(' ')
    })
  });
  if (!res.ok) throw new Error('client registration failed');
  return await res.json(); // { client_id, token_endpoint_auth_method }
}

Cache the result. You only need to re-register if you change scopes or redirect URIs. The client identifier itself is not a credential; it pairs with PKCE for authentication.

Step 3: Authorize, Then Exchange

The authorize step is the only part of the flow that needs a browser or a system browser handoff. After consent, your loopback server receives the authorization code and exchanges it for tokens.

function authorizeUrl({ clientId, challenge, state, scope }) {
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: clientId,
    redirect_uri: 'http://127.0.0.1:51777/callback',
    scope,
    code_challenge: challenge,
    code_challenge_method: 'S256',
    state,
  });
  return 'https://yeti-api.snowcoder.ai/oauth/authorize?' + params.toString();
}

async function exchangeCode({ code, verifier, clientId }) {
  const res = await fetch('https://yeti-api.snowcoder.ai/oauth/token', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'authorization_code',
      code,
      code_verifier: verifier,
      client_id: clientId,
    }),
  });
  if (!res.ok) throw new Error('token exchange failed');
  return await res.json(); // { access_token, refresh_token, expires_in }
}

For unattended workflows you do this once at provisioning time, then store the refresh token in a secret manager. The refresh-token rotation contract means you must atomically swap the stored token after each refresh.

Step 4: Refresh-Token Rotation

SnowCoder MCP rotates refresh tokens on every use. The previous token is invalid the moment a new one is issued. Your client must save the new token before it makes another call.

async function refreshTokens({ refreshToken, clientId, store }) {
  const res = await fetch('https://yeti-api.snowcoder.ai/oauth/token', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: clientId,
    }),
  });
  if (!res.ok) {
    // 400 invalid_grant means replay or revocation.
    // Re-authenticate from scratch.
    throw new Error('refresh failed: re-auth required');
  }
  const tokens = await res.json();
  await store.replaceAtomically(tokens); // critical
  return tokens;
}

The atomic replacement matters. If you write the access token first and crash before writing the refresh token, the next refresh attempt is a replay and the server revokes the session.

Step 5: Calling MCP Tools

Once you have an access token, calling SnowCoder MCP tools is standard JSON over HTTP. A minimal tool call helper:

async function callTool({ accessToken, tool, args }) {
  const res = await fetch('https://yeti-api.snowcoder.ai/mcp', {
    method: 'POST',
    headers: {
      'authorization': 'Bearer ' + accessToken,
      'content-type': 'application/json',
      'accept': 'application/json, text/event-stream',
    },
    body: JSON.stringify({
      jsonrpc: '2.0',
      id: 1,
      method: 'tools/call',
      params: { name: tool, arguments: args },
    }),
  });
  if (res.status === 401) throw new Error('expired_token');
  if (res.status === 403) throw new Error('insufficient_scope');
  if (!res.ok) throw new Error('tool error: ' + res.status);
  return await res.json();
}

Two response codes that you will see in practice: 401 means refresh the access token and retry once. 403 means your scope set is too narrow. Do not retry on 403; re-authorize with a wider scope set.

A successful call returns structured JSON. Tool results are not free text; they are typed payloads you can read directly into your control flow.

Step 6: A Real Workflow End to End

Put it together for a nightly build runner. The script starts a Yeti Build Agent run, polls until it finishes, and emits a summary keyed by AC class:

async function nightlyBuild({ accessToken, projectId }) {
  const start = await callTool({
    accessToken, tool: 'start_build',
    args: {
      project: projectId,
      autoHeal: true,
      healBudget: { cap: 2.50, maxAttempts: 5 },
    }
  });

  let status = start.status;
  while (status === 'running' || status === 'queued') {
    await new Promise(r => setTimeout(r, 5000));
    const poll = await callTool({
      accessToken, tool: 'get_build_status', args: { id: start.id }
    });
    status = poll.status;
  }

  const diffs = await callTool({
    accessToken, tool: 'get_build_artifacts', args: { id: start.id }
  });

  const summary = {};
  for (const d of diffs.entries) {
    const cls = d.artifactClass;
    summary[cls] = summary[cls] || { passed: 0, healed: 0, failed: 0 };
    if (d.validation.result === 'PASS' && d.healAttempts === 0) summary[cls].passed++;
    else if (d.validation.result === 'PASS') summary[cls].healed++;
    else summary[cls].failed++;
  }
  return summary;
}

The shape of this is the shape of any production client. The auth/refresh layer is shared. The retry-on-401 logic is shared. The 403 handling is shared. Everything else is application code.

What to Add for Real Production

  • Atomic token storage. Use your secret manager's versioned write. Do not stash tokens on local disk for unattended workloads.
  • Structured logging. Log tool, status, scope, and elapsed time. Do not log token values, ever.
  • Backoff on retries. 401-driven refresh-and-retry should be capped at one cycle. Beyond that, fail loudly.
  • Per-tool circuit breakers. If start_build fails repeatedly, do not keep firing it. Break the circuit, raise an alert.
  • Health probes. A lightweight kb:read call against the knowledge base makes a clean liveness check.

Choosing the Right Scope Set

The scope set is the single most important security decision your custom client makes. A few rules of thumb:

  • If the client only enriches user prompts, kb:read is often enough.
  • If the client reads project state for dashboards, add projects:read.
  • If the client starts Yeti Build Agent runs, you need builds:read and builds:write.
  • If the client manages project metadata, add projects:write.
  • Never request scopes "just in case." The MCP server will not let your client expand them silently, but a narrow scope set is also a narrow blast radius if the token leaks.

Practical Notes from Guide v2.0

The v2.0 API integration guide adds a few operational details worth wiring into any custom client before it goes near production:

  • Session handshake. A full MCP session opens with an initialize call before tools/call, and subsequent requests carry the MCP-Protocol-Version header. An MCP client library does this automatically, which is one more reason to build on one rather than hand-rolling the JSON-RPC.
  • Generous timeouts. guru-mode answers can take one to two minutes (verified around 113 seconds). Allow at least 180 seconds on the HTTP client, or long calls will look like failures.
  • Non-streaming, so poll. Each call returns one complete payload; there is no Server-Sent-Events stream on the API path. Long-running work is asynchronous: start it, then poll the status tools until it completes.
  • Rate limit. 200 requests per minute per credential. Back off on 429 rather than retrying hot, especially in polling loops.
  • Body cap. Request bodies are capped at 10 MB platform-wide. Chunk large uploads accordingly.

The connection details, tools, and safety reference, plus how to connect Claude Code and Codex, are on the API reference page, and the governed build pipeline post covers how the v2.0 lifecycle keeps a scripted client from deploying without an explicit, separate approval.

When To Stop and Use a Supported Client

If your "custom" client ends up looking like a chat window with a model and tools, you are reinventing Claude Code or Cursor with worse ergonomics. The right time to write your own client is when the consumer is not a developer. Service accounts driving nightly automation, internal portals exposing one specific tool, or CI runners checking the Yeti Build Agent before promotion are all good fits. A general-purpose ServiceNow assistant for engineers is not.

The MCP server is identical regardless of the client. SnowCoder gives you the supported list so you can stay in front of common cases and build custom only where it matters.

Automate ServiceNow workflows on your terms.

MCP is on every tier. Start with a free SnowCoder account, or talk to us about service-account access for unattended automation.