Skip to content

Commit 7cfa855

Browse files
committed
Merge branch 'main' into feature/zod-v3-compat
2 parents 6e4cfa2 + 2e67eb5 commit 7cfa855

30 files changed

+4108
-264
lines changed

README.md

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,7 +1175,7 @@ await server.connect(transport);
11751175

11761176
### Eliciting User Input
11771177

1178-
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:
1178+
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:
11791179

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

1277-
Client-side: Handle elicitation requests
1278+
On the client side, handle form elicitation requests:
12781279

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

1302-
**Note**: Elicitation requires client support. Clients must declare the `elicitation` capability during initialization.
1303+
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.
1304+
1305+
Elicitation is a client capability. Clients must declare the `elicitation` capability during initialization:
1306+
1307+
```typescript
1308+
const client = new Client(
1309+
{
1310+
name: 'example-client',
1311+
version: '1.0.0'
1312+
},
1313+
{
1314+
capabilities: {
1315+
elicitation: {
1316+
form: {}
1317+
}
1318+
}
1319+
}
1320+
);
1321+
```
1322+
1323+
**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.
1324+
1325+
### Eliciting URL Actions
1326+
1327+
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.
1328+
1329+
```typescript
1330+
// Server-side: Prompt the user to navigate to a URL
1331+
const result = await server.server.elicitInput({
1332+
mode: 'url',
1333+
message: 'Please enter your API key',
1334+
elicitationId: '550e8400-e29b-41d4-a716-446655440000',
1335+
url: 'http://localhost:3000/api-key'
1336+
});
1337+
1338+
// Alternative, return an error from within a tool:
1339+
throw new UrlElicitationRequiredError([
1340+
{
1341+
mode: 'url',
1342+
message: 'This tool requires a payment confirmation. Open the link to confirm payment!',
1343+
url: `http://localhost:${MCP_PORT}/confirm-payment?session=${sessionId}&elicitation=${elicitationId}&cartId=${encodeURIComponent(cartId)}`,
1344+
elicitationId: '550e8400-e29b-41d4-a716-446655440000'
1345+
}
1346+
]);
1347+
```
1348+
1349+
On the client side, handle URL elicitation requests:
1350+
1351+
```typescript
1352+
client.setRequestHandler(ElicitRequestSchema, async request => {
1353+
if (request.params.mode !== 'url') {
1354+
throw new McpError(ErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`);
1355+
}
1356+
1357+
// At a minimum, implement a UI that:
1358+
// - Display the full URL and server reason to prevent phishing
1359+
// - Explicitly ask the user for consent, with clear decline/cancel options
1360+
// - Open the URL in the system (not embedded) browser
1361+
// Optionally, listen for a `nofifications/elicitation/complete` message from the server
1362+
});
1363+
```
1364+
1365+
Elicitation is a client capability. Clients must declare the `elicitation` capability during initialization:
1366+
1367+
```typescript
1368+
const client = new Client(
1369+
{
1370+
name: 'example-client',
1371+
version: '1.0.0'
1372+
},
1373+
{
1374+
capabilities: {
1375+
elicitation: {
1376+
url: {}
1377+
}
1378+
}
1379+
}
1380+
);
1381+
```
13031382

13041383
### Writing MCP Clients
13051384

src/client/auth.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2174,6 +2174,135 @@ describe('OAuth Authorization', () => {
21742174
expect(body.get('refresh_token')).toBe('refresh123');
21752175
});
21762176

2177+
it('uses scopes_supported from PRM when scope is not provided', async () => {
2178+
// Mock PRM with scopes_supported
2179+
mockFetch.mockImplementation(url => {
2180+
const urlString = url.toString();
2181+
2182+
if (urlString.includes('/.well-known/oauth-protected-resource')) {
2183+
return Promise.resolve({
2184+
ok: true,
2185+
status: 200,
2186+
json: async () => ({
2187+
resource: 'https://api.example.com/',
2188+
authorization_servers: ['https://auth.example.com'],
2189+
scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin']
2190+
})
2191+
});
2192+
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
2193+
return Promise.resolve({
2194+
ok: true,
2195+
status: 200,
2196+
json: async () => ({
2197+
issuer: 'https://auth.example.com',
2198+
authorization_endpoint: 'https://auth.example.com/authorize',
2199+
token_endpoint: 'https://auth.example.com/token',
2200+
registration_endpoint: 'https://auth.example.com/register',
2201+
response_types_supported: ['code'],
2202+
code_challenge_methods_supported: ['S256']
2203+
})
2204+
});
2205+
} else if (urlString.includes('/register')) {
2206+
return Promise.resolve({
2207+
ok: true,
2208+
status: 200,
2209+
json: async () => ({
2210+
client_id: 'test-client-id',
2211+
client_secret: 'test-client-secret',
2212+
redirect_uris: ['http://localhost:3000/callback'],
2213+
client_name: 'Test Client'
2214+
})
2215+
});
2216+
}
2217+
2218+
return Promise.resolve({ ok: false, status: 404 });
2219+
});
2220+
2221+
// Mock provider methods - no scope in clientMetadata
2222+
(mockProvider.clientInformation as Mock).mockResolvedValue(undefined);
2223+
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
2224+
mockProvider.saveClientInformation = vi.fn();
2225+
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
2226+
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);
2227+
2228+
// Call auth without scope parameter
2229+
const result = await auth(mockProvider, {
2230+
serverUrl: 'https://api.example.com/'
2231+
});
2232+
2233+
expect(result).toBe('REDIRECT');
2234+
2235+
// Verify the authorization URL includes the scopes from PRM
2236+
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
2237+
const authUrl: URL = redirectCall[0];
2238+
expect(authUrl.searchParams.get('scope')).toBe('mcp:read mcp:write mcp:admin');
2239+
});
2240+
2241+
it('prefers explicit scope parameter over scopes_supported from PRM', async () => {
2242+
// Mock PRM with scopes_supported
2243+
mockFetch.mockImplementation(url => {
2244+
const urlString = url.toString();
2245+
2246+
if (urlString.includes('/.well-known/oauth-protected-resource')) {
2247+
return Promise.resolve({
2248+
ok: true,
2249+
status: 200,
2250+
json: async () => ({
2251+
resource: 'https://api.example.com/',
2252+
authorization_servers: ['https://auth.example.com'],
2253+
scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin']
2254+
})
2255+
});
2256+
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
2257+
return Promise.resolve({
2258+
ok: true,
2259+
status: 200,
2260+
json: async () => ({
2261+
issuer: 'https://auth.example.com',
2262+
authorization_endpoint: 'https://auth.example.com/authorize',
2263+
token_endpoint: 'https://auth.example.com/token',
2264+
registration_endpoint: 'https://auth.example.com/register',
2265+
response_types_supported: ['code'],
2266+
code_challenge_methods_supported: ['S256']
2267+
})
2268+
});
2269+
} else if (urlString.includes('/register')) {
2270+
return Promise.resolve({
2271+
ok: true,
2272+
status: 200,
2273+
json: async () => ({
2274+
client_id: 'test-client-id',
2275+
client_secret: 'test-client-secret',
2276+
redirect_uris: ['http://localhost:3000/callback'],
2277+
client_name: 'Test Client'
2278+
})
2279+
});
2280+
}
2281+
2282+
return Promise.resolve({ ok: false, status: 404 });
2283+
});
2284+
2285+
// Mock provider methods
2286+
(mockProvider.clientInformation as Mock).mockResolvedValue(undefined);
2287+
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
2288+
mockProvider.saveClientInformation = vi.fn();
2289+
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
2290+
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);
2291+
2292+
// Call auth with explicit scope parameter
2293+
const result = await auth(mockProvider, {
2294+
serverUrl: 'https://api.example.com/',
2295+
scope: 'mcp:read'
2296+
});
2297+
2298+
expect(result).toBe('REDIRECT');
2299+
2300+
// Verify the authorization URL uses the explicit scope, not scopes_supported
2301+
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
2302+
const authUrl: URL = redirectCall[0];
2303+
expect(authUrl.searchParams.get('scope')).toBe('mcp:read');
2304+
});
2305+
21772306
it('fetches AS metadata with path from serverUrl when PRM returns external AS', async () => {
21782307
// Mock PRM discovery that returns an external AS
21792308
mockFetch.mockImplementation(url => {

src/client/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@ async function authInternal(
447447
clientInformation,
448448
state,
449449
redirectUrl: provider.redirectUrl,
450-
scope: scope || provider.clientMetadata.scope,
450+
scope: scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope,
451451
resource
452452
});
453453

0 commit comments

Comments
 (0)