Skip to content
Merged
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
85 changes: 82 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1175,7 +1175,7 @@ await server.connect(transport);

### Eliciting User Input

MCP servers can request additional information from users through the elicitation feature. This is useful for interactive workflows where the server needs user input or confirmation:
MCP servers can request non-sensitive information from users through the form elicitation capability. This is useful for interactive workflows where the server needs user input or confirmation:

```typescript
// Server-side: Restaurant booking tool that asks for alternatives
Expand Down Expand Up @@ -1208,6 +1208,7 @@ server.registerTool(
if (!available) {
// Ask user if they want to try alternative dates
const result = await server.server.elicitInput({
mode: 'form',
message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`,
requestedSchema: {
type: 'object',
Expand Down Expand Up @@ -1274,7 +1275,7 @@ server.registerTool(
);
```

Client-side: Handle elicitation requests
On the client side, handle form elicitation requests:

```typescript
// This is a placeholder - implement based on your UI framework
Expand All @@ -1299,7 +1300,85 @@ client.setRequestHandler(ElicitRequestSchema, async request => {
});
```

**Note**: Elicitation requires client support. Clients must declare the `elicitation` capability during initialization.
When calling `server.elicitInput`, prefer to explicitly set `mode: 'form'` for new code. Omitting the mode continues to work for backwards compatibility and defaults to form elicitation.

Elicitation is a client capability. Clients must declare the `elicitation` capability during initialization:

```typescript
const client = new Client(
{
name: 'example-client',
version: '1.0.0'
},
{
capabilities: {
elicitation: {
form: {}
}
}
}
);
```

**Note**: Form elicitation **must** only be used to gather non-sensitive information. For sensitive information such as API keys or secrets, use URL elicitation instead.

### Eliciting URL Actions

MCP servers can prompt the user to perform a URL-based action through URL elicitation. This is useful for securely gathering sensitive information such as API keys or secrets, or for redirecting users to secure web-based flows.

```typescript
// Server-side: Prompt the user to navigate to a URL
const result = await server.server.elicitInput({
mode: 'url',
message: 'Please enter your API key',
elicitationId: '550e8400-e29b-41d4-a716-446655440000',
url: 'http://localhost:3000/api-key'
});

// Alternative, return an error from within a tool:
throw new UrlElicitationRequiredError([
{
mode: 'url',
message: 'This tool requires a payment confirmation. Open the link to confirm payment!',
url: `http://localhost:${MCP_PORT}/confirm-payment?session=${sessionId}&elicitation=${elicitationId}&cartId=${encodeURIComponent(cartId)}`,
elicitationId: '550e8400-e29b-41d4-a716-446655440000'
}
]);
```

On the client side, handle URL elicitation requests:

```typescript
client.setRequestHandler(ElicitRequestSchema, async request => {
if (request.params.mode !== 'url') {
throw new McpError(ErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`);
}

// At a minimum, implement a UI that:
// - Display the full URL and server reason to prevent phishing
// - Explicitly ask the user for consent, with clear decline/cancel options
// - Open the URL in the system (not embedded) browser
// Optionally, listen for a `nofifications/elicitation/complete` message from the server
});
```

Elicitation is a client capability. Clients must declare the `elicitation` capability during initialization:

```typescript
const client = new Client(
{
name: 'example-client',
version: '1.0.0'
},
{
capabilities: {
elicitation: {
url: {}
}
}
}
);
```

### Writing MCP Clients

Expand Down
129 changes: 129 additions & 0 deletions src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2174,6 +2174,135 @@ describe('OAuth Authorization', () => {
expect(body.get('refresh_token')).toBe('refresh123');
});

it('uses scopes_supported from PRM when scope is not provided', async () => {
// Mock PRM with scopes_supported
mockFetch.mockImplementation(url => {
const urlString = url.toString();

if (urlString.includes('/.well-known/oauth-protected-resource')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
resource: 'https://api.example.com/',
authorization_servers: ['https://auth.example.com'],
scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin']
})
});
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
registration_endpoint: 'https://auth.example.com/register',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
});
} else if (urlString.includes('/register')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
client_id: 'test-client-id',
client_secret: 'test-client-secret',
redirect_uris: ['http://localhost:3000/callback'],
client_name: 'Test Client'
})
});
}

return Promise.resolve({ ok: false, status: 404 });
});

// Mock provider methods - no scope in clientMetadata
(mockProvider.clientInformation as Mock).mockResolvedValue(undefined);
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
mockProvider.saveClientInformation = vi.fn();
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);

// Call auth without scope parameter
const result = await auth(mockProvider, {
serverUrl: 'https://api.example.com/'
});

expect(result).toBe('REDIRECT');

// Verify the authorization URL includes the scopes from PRM
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
const authUrl: URL = redirectCall[0];
expect(authUrl.searchParams.get('scope')).toBe('mcp:read mcp:write mcp:admin');
});

it('prefers explicit scope parameter over scopes_supported from PRM', async () => {
// Mock PRM with scopes_supported
mockFetch.mockImplementation(url => {
const urlString = url.toString();

if (urlString.includes('/.well-known/oauth-protected-resource')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
resource: 'https://api.example.com/',
authorization_servers: ['https://auth.example.com'],
scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin']
})
});
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
registration_endpoint: 'https://auth.example.com/register',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
});
} else if (urlString.includes('/register')) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
client_id: 'test-client-id',
client_secret: 'test-client-secret',
redirect_uris: ['http://localhost:3000/callback'],
client_name: 'Test Client'
})
});
}

return Promise.resolve({ ok: false, status: 404 });
});

// Mock provider methods
(mockProvider.clientInformation as Mock).mockResolvedValue(undefined);
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
mockProvider.saveClientInformation = vi.fn();
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);

// Call auth with explicit scope parameter
const result = await auth(mockProvider, {
serverUrl: 'https://api.example.com/',
scope: 'mcp:read'
});

expect(result).toBe('REDIRECT');

// Verify the authorization URL uses the explicit scope, not scopes_supported
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
const authUrl: URL = redirectCall[0];
expect(authUrl.searchParams.get('scope')).toBe('mcp:read');
});

it('fetches AS metadata with path from serverUrl when PRM returns external AS', async () => {
// Mock PRM discovery that returns an external AS
mockFetch.mockImplementation(url => {
Expand Down
2 changes: 1 addition & 1 deletion src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ async function authInternal(
clientInformation,
state,
redirectUrl: provider.redirectUrl,
scope: scope || provider.clientMetadata.scope,
scope: scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope,
resource
});

Expand Down
Loading
Loading