NIN Connect Docs
Platform (Self-Host)MCP

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:

  1. Discover auth metadata with fallbacks
  2. Use OAuth 2.0 Authorization Code + PKCE when auth is required
  3. Support both auto-registration and manual client credentials
  4. Persist the same client credentials for the full token lifetime
  5. 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:

  1. Try RFC 9470 protected resource metadata
  2. Try RFC 8414 authorization server metadata
  3. Try path-aware RFC 8414 metadata for issuers with a path
  4. Trigger a 401 WWW-Authenticate challenge and parse resource_metadata or authorization_uri
  5. 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:

  1. Generate code_verifier
  2. Derive code_challenge using S256
  3. Create a short-lived signed state
  4. Redirect to the provider authorization endpoint
  5. Exchange the callback code using the original code_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_verifier server-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-stream

Without 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:

  1. the end user
  2. the developer
  3. 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:

  1. End-user override, if explicitly allowed
  2. Developer-level override
  3. Plugin default managed by admin

That gives you flexibility without breaking the OAuth lifecycle.

  • 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

On this page