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 3 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
130 changes: 130 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,135 @@ 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);
```

#### Initial Access Token Support (RFC 7591)

For authorization servers that require pre-authorization for dynamic client registration, the SDK supports initial access tokens with multi-level fallback:

##### Method 1: Transport Configuration (Highest Priority)
```typescript
const transport = new StreamableHTTPClientTransport(serverUrl, {
authProvider,
initialAccessToken: "your-initial-access-token"
});
```

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

async initialAccessToken() {
// Load from secure storage, API call, etc.
return await this.loadFromSecureStorage('initial_access_token');
}
}
```

##### Method 3: Environment Variable
```bash
export OAUTH_INITIAL_ACCESS_TOKEN="your-initial-access-token"
```

The SDK will automatically try these methods in order:
1. Explicit `initialAccessToken` parameter (highest priority)
2. Provider's `initialAccessToken()` method
3. `OAUTH_INITIAL_ACCESS_TOKEN` environment variable
4. None (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
}
}
```

### 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.

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

describe("initial access token support", () => {
it("includes initial access token from explicit parameter", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validClientInfo,
});

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

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

it("includes initial access 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(),
initialAccessToken: jest.fn().mockResolvedValue("provider-token"),
};

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

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

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

it("prioritizes explicit parameter over 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(),
initialAccessToken: jest.fn().mockResolvedValue("provider-token"),
};

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

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

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

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