Integrating your MCP client
Build an MCP client flow like NIN Connect with discovery fallbacks, PKCE, dynamic registration, public-server support, and secure token refresh.
Goal
If you want to build your own MCP client the same way NIN Connect does, the safest approach is:
- Discover auth metadata with fallbacks
- Use OAuth 2.0 Authorization Code + PKCE when auth is required
- Support both auto-registration and manual client credentials
- Persist the same client credentials for the full token lifetime
- Prefer Streamable HTTP and fall back to SSE only when needed
This page mirrors the behavior NIN uses internally for plugin-builder.
Discovery flow
Do not assume every MCP server exposes only one .well-known endpoint shape.
Use this order:
- Try RFC 9470 protected resource metadata
- Try RFC 8414 authorization server metadata
- Try path-aware RFC 8414 metadata for issuers with a path
- Trigger a
401 WWW-Authenticatechallenge and parseresource_metadataorauthorization_uri - If the server responds normally without auth, treat it as a public MCP server
Why this matters
- Notion follows the more standard protected-resource flow
- GitHub uses challenge-based discovery and a path-aware issuer
- Some servers, such as public market-data MCP endpoints, do not require OAuth at all
Example discovery helper
type OAuthMetadata = {
authorization_endpoint: string;
token_endpoint: string;
registration_endpoint?: string;
scopes_supported?: string[];
};
const fetchAuthServerMetadata = async (
authServerUrl: string
): Promise<OAuthMetadata | null> => {
const parsed = new URL(authServerUrl);
const candidates: string[] = [];
if (parsed.pathname && parsed.pathname !== "/") {
candidates.push(
new URL(
`/.well-known/oauth-authorization-server${parsed.pathname}`,
parsed.origin
).toString()
);
}
candidates.push(
new URL("/.well-known/oauth-authorization-server", authServerUrl).toString()
);
for (const candidate of candidates) {
const res = await fetch(candidate).catch(() => null);
if (res?.ok) {
const data = (await res.json()) as OAuthMetadata;
if (data.authorization_endpoint && data.token_endpoint) {
return data;
}
}
}
return null;
};PKCE flow
When the server requires OAuth:
- Generate
code_verifier - Derive
code_challengeusingS256 - Create a short-lived signed
state - Redirect to the provider authorization endpoint
- Exchange the callback
codeusing the originalcode_verifier
import { createHash, randomBytes } from "crypto";
const base64Url = (buffer: Buffer) =>
buffer
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
export const generateCodeVerifier = () => base64Url(randomBytes(32));
export const generateCodeChallenge = (verifier: string) =>
base64Url(createHash("sha256").update(verifier).digest());Keep the
code_verifierserver-side only and expire it quickly. Never send it until the token exchange step.
Dynamic registration vs manual credentials
If registration_endpoint exists, you can attempt RFC 7591 dynamic client registration.
If not:
- ask the admin for a client ID
- optionally ask for a client secret
- show the callback URL clearly
- save the credentials securely
This is necessary for providers like GitHub or services that intentionally disable auto-registration.
Public MCP servers
If discovery fails but a probe request succeeds without authentication, store the server as:
{
"authType": "none",
"oauthMetadata": null
}That prevents you from forcing client IDs or OAuth forms on a server that does not need them.
Transport details
Use Streamable HTTP first, then SSE if needed.
Some hosted servers require this Accept header even for discovery or initialize requests:
Accept: application/json, text/event-streamWithout it, some servers return 406 Not Acceptable.
Token refresh
Refresh tokens must use the same client identity that created the grant.
In NIN Connect we persist:
- the MCP access token
- the MCP refresh token
- the effective client ID
- the effective client secret if one was used
That is important when credentials can come from:
- the end user
- the developer
- the admin
If you change client credentials during refresh, some providers will return invalid_client or invalid_grant.
Credential resolution pattern
For multi-tenant platforms, use a deterministic resolution chain:
- End-user override, if explicitly allowed
- Developer-level override
- Plugin default managed by admin
That gives you flexibility without breaking the OAuth lifecycle.
Recommended storage rules
- Encrypt client secrets and tokens at rest
- Keep callback state signed and short-lived
- Log discovery and refresh failures without leaking secrets
- Do token exchange and refresh server-side only
- Show reconnect UI when refresh returns
invalid_grant
References
Building an MCP Server
Step-by-step guide for services to build a remote MCP server with OAuth 2.1 (PKCE), session management, and Streamable HTTP or SSE transport — aligned with the official MCP authorization and security guidance.
Security best practices
Secure MCP usage in NIN Connect with prompt-injection defenses, trusted-server controls, credential hygiene, and human-in-the-loop execution.