Skip to content

feat: add multi-level initial access token support for OAuth 2.0 Dynamic Client Registration (RFC 7591) #773

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
- [Dynamic Servers](#dynamic-servers)
- [Low-Level Server](#low-level-server)
- [Writing MCP Clients](#writing-mcp-clients)
- [OAuth Client Configuration](#oauth-client-configuration)
- [Proxy Authorization Requests Upstream](#proxy-authorization-requests-upstream)
- [Backwards Compatibility](#backwards-compatibility)
- [Documentation](#documentation)
Expand Down Expand Up @@ -1162,6 +1163,134 @@ const result = await client.callTool({

```

### OAuth Client Configuration

The MCP SDK provides comprehensive OAuth 2.0 client support with dynamic client registration and multiple authentication methods.

#### Basic OAuth Client Setup

```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";

class MyOAuthProvider implements OAuthClientProvider {
get redirectUrl() { return "http://localhost:3000/callback"; }

get clientMetadata() {
return {
redirect_uris: ["http://localhost:3000/callback"],
client_name: "My MCP Client",
scope: "mcp:tools mcp:resources"
};
}

async clientInformation() {
// Return stored client info or undefined for dynamic registration
return this.loadClientInfo();
}

async saveClientInformation(info) {
// Store client info after registration
await this.storeClientInfo(info);
}

async tokens() {
// Return stored tokens or undefined
return this.loadTokens();
}

async saveTokens(tokens) {
// Store OAuth tokens
await this.storeTokens(tokens);
}

async redirectToAuthorization(url) {
// Redirect user to authorization URL
window.location.href = url.toString();
}

async saveCodeVerifier(verifier) {
// Store PKCE code verifier
sessionStorage.setItem('code_verifier', verifier);
}

async codeVerifier() {
// Return stored code verifier
return sessionStorage.getItem('code_verifier');
}
}

const authProvider = new MyOAuthProvider();
const transport = new StreamableHTTPClientTransport(serverUrl, {
authProvider
});

const client = new Client({ name: "oauth-client", version: "1.0.0" });
await client.connect(transport);
```

#### DCR Registration Access Token Support (RFC 7591)

For authorization servers that require pre-authorization for dynamic client registration, the SDK supports DCR registration access tokens (called "initial access token" in RFC 7591).

The SDK automatically checks for a `DCR_REGISTRATION_ACCESS_TOKEN` environment variable. For custom logic, implement the `dcrRegistrationAccessToken()` method in your OAuth provider:

##### Method 1: Environment Variable (Default)
```bash
export DCR_REGISTRATION_ACCESS_TOKEN="your-initial-access-token"
```

##### Method 2: Custom Provider Method
```typescript
class MyOAuthProvider implements OAuthClientProvider {
// ... other methods ...

async dcrRegistrationAccessToken() {
// Custom fallback logic: check parameter, then env var, then storage
return this.explicitToken
|| process.env.DCR_REGISTRATION_ACCESS_TOKEN
|| await this.loadFromSecureStorage('dcr_registration_access_token');
}
}
```

The SDK will:
1. Call your `dcrRegistrationAccessToken()` method (if implemented)
2. Fall back to `DCR_REGISTRATION_ACCESS_TOKEN` environment variable
3. Proceed without token (for servers that don't require pre-authorization)

#### Complete OAuth Flow Example

```typescript
// After user authorization, handle the callback
async function handleAuthCallback(authorizationCode: string) {
await transport.finishAuth(authorizationCode);
// Client is now authenticated and ready to use

const result = await client.callTool({
name: "example-tool",
arguments: { param: "value" }
});
}

// Start the OAuth flow
try {
await client.connect(transport);
console.log("Already authenticated");
} catch (error) {
if (error instanceof UnauthorizedError) {
console.log("OAuth authorization required");
// User will be redirected to authorization server
// Handle the callback when they return
}
}
```

For complete working examples of OAuth with DCR token support, see:
- [`src/examples/client/simpleOAuthClient.ts`](src/examples/client/simpleOAuthClient.ts) - Basic OAuth client with DCR support
- [`src/examples/client/advancedDcrOAuthClient.ts`](src/examples/client/advancedDcrOAuthClient.ts) - Advanced DCR strategies for production

### Proxy Authorization Requests Upstream

You can proxy OAuth requests to an external authorization provider:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

207 changes: 207 additions & 0 deletions src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1158,6 +1158,213 @@ describe("OAuth Authorization", () => {
})
).rejects.toThrow("Dynamic client registration failed");
});

describe("DCR registration access token support", () => {
it("includes DCR token from provider method", async () => {
const mockProvider: OAuthClientProvider = {
get redirectUrl() { return "http://localhost:3000/callback"; },
get clientMetadata() { return validClientMetadata; },
clientInformation: jest.fn(),
tokens: jest.fn(),
saveTokens: jest.fn(),
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: jest.fn(),
dcrRegistrationAccessToken: jest.fn().mockResolvedValue("provider-dcr-token"),
};

mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validClientInfo,
});

await registerClient("https://auth.example.com", {
clientMetadata: validClientMetadata,
provider: mockProvider,
});

expect(mockProvider.dcrRegistrationAccessToken).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(
expect.objectContaining({
href: "https://auth.example.com/register",
}),
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer provider-dcr-token",
},
body: JSON.stringify(validClientMetadata),
})
);
});

it("falls back to environment variable when provider method not implemented", async () => {
const originalEnv = process.env.DCR_REGISTRATION_ACCESS_TOKEN;
process.env.DCR_REGISTRATION_ACCESS_TOKEN = "env-dcr-token";

try {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validClientInfo,
});

await registerClient("https://auth.example.com", {
clientMetadata: validClientMetadata,
// No provider passed
});

expect(mockFetch).toHaveBeenCalledWith(
expect.objectContaining({
href: "https://auth.example.com/register",
}),
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer env-dcr-token",
},
body: JSON.stringify(validClientMetadata),
})
);
} finally {
if (originalEnv !== undefined) {
process.env.DCR_REGISTRATION_ACCESS_TOKEN = originalEnv;
} else {
delete process.env.DCR_REGISTRATION_ACCESS_TOKEN;
}
}
});

it("prioritizes provider method over environment variable", async () => {
const originalEnv = process.env.DCR_REGISTRATION_ACCESS_TOKEN;
process.env.DCR_REGISTRATION_ACCESS_TOKEN = "env-dcr-token";

try {
const mockProvider: OAuthClientProvider = {
get redirectUrl() { return "http://localhost:3000/callback"; },
get clientMetadata() { return validClientMetadata; },
clientInformation: jest.fn(),
tokens: jest.fn(),
saveTokens: jest.fn(),
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: jest.fn(),
dcrRegistrationAccessToken: jest.fn().mockResolvedValue("provider-dcr-token"),
};

mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validClientInfo,
});

await registerClient("https://auth.example.com", {
clientMetadata: validClientMetadata,
provider: mockProvider,
});

expect(mockProvider.dcrRegistrationAccessToken).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(
expect.objectContaining({
href: "https://auth.example.com/register",
}),
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer provider-dcr-token",
},
body: JSON.stringify(validClientMetadata),
})
);
} finally {
if (originalEnv !== undefined) {
process.env.DCR_REGISTRATION_ACCESS_TOKEN = originalEnv;
} else {
delete process.env.DCR_REGISTRATION_ACCESS_TOKEN;
}
}
});

it("handles provider method returning undefined and falls back to env var", async () => {
const originalEnv = process.env.DCR_REGISTRATION_ACCESS_TOKEN;
process.env.DCR_REGISTRATION_ACCESS_TOKEN = "env-dcr-token";

try {
const mockProvider: OAuthClientProvider = {
get redirectUrl() { return "http://localhost:3000/callback"; },
get clientMetadata() { return validClientMetadata; },
clientInformation: jest.fn(),
tokens: jest.fn(),
saveTokens: jest.fn(),
redirectToAuthorization: jest.fn(),
saveCodeVerifier: jest.fn(),
codeVerifier: jest.fn(),
dcrRegistrationAccessToken: jest.fn().mockResolvedValue(undefined),
};

mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validClientInfo,
});

await registerClient("https://auth.example.com", {
clientMetadata: validClientMetadata,
provider: mockProvider,
});

expect(mockProvider.dcrRegistrationAccessToken).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(
expect.objectContaining({
href: "https://auth.example.com/register",
}),
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer env-dcr-token",
},
body: JSON.stringify(validClientMetadata),
})
);
} finally {
if (originalEnv !== undefined) {
process.env.DCR_REGISTRATION_ACCESS_TOKEN = originalEnv;
} else {
delete process.env.DCR_REGISTRATION_ACCESS_TOKEN;
}
}
});

it("registers without authorization header when no token available", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validClientInfo,
});

await registerClient("https://auth.example.com", {
clientMetadata: validClientMetadata,
});

expect(mockFetch).toHaveBeenCalledWith(
expect.objectContaining({
href: "https://auth.example.com/register",
}),
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(validClientMetadata),
})
);
});
});
});

describe("auth function", () => {
Expand Down
Loading