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.
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://mcp.snowcoder.ai/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://mcp.snowcoder.ai/authorize?' + params.toString();
}
async function exchangeCode({ code, verifier, clientId }) {
const res = await fetch('https://mcp.snowcoder.ai/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://mcp.snowcoder.ai/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://mcp.snowcoder.ai/tools/' + tool, {
method: 'POST',
headers: {
'authorization': 'Bearer ' + accessToken,
'content-type': 'application/json',
},
body: JSON.stringify(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: 'builds.start',
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: 'builds.status', args: { id: start.id }
});
status = poll.status;
}
const diffs = await callTool({
accessToken, tool: 'builds.diffs', 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
builds.startfails repeatedly, do not keep firing it. Break the circuit, raise an alert. - Health probes. A lightweight
kb:readcall 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:readis often enough. - If the client reads project state for dashboards, add
projects:read. - If the client starts Yeti Build Agent runs, you need
builds:readandbuilds: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.
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.
Related reading
- SnowCoder MCP details the supported clients, scopes, and transports.
- OAuth 2.1 + PKCE for MCP servers covers the security model in depth.
- Yeti Build Agent explains the operations your custom client will most often invoke.
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.