Skip to content

Commit 038aff6

Browse files
Merge branch 'main' into improvement/handle-string-expires_in
2 parents 9c8139c + 2e67eb5 commit 038aff6

28 files changed

+3744
-238
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
@@ -2212,6 +2212,135 @@ describe('OAuth Authorization', () => {
22122212
expect(body.get('refresh_token')).toBe('refresh123');
22132213
});
22142214

2215+
it('uses scopes_supported from PRM when scope is not provided', async () => {
2216+
// Mock PRM with scopes_supported
2217+
mockFetch.mockImplementation(url => {
2218+
const urlString = url.toString();
2219+
2220+
if (urlString.includes('/.well-known/oauth-protected-resource')) {
2221+
return Promise.resolve({
2222+
ok: true,
2223+
status: 200,
2224+
json: async () => ({
2225+
resource: 'https://api.example.com/',
2226+
authorization_servers: ['https://auth.example.com'],
2227+
scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin']
2228+
})
2229+
});
2230+
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
2231+
return Promise.resolve({
2232+
ok: true,
2233+
status: 200,
2234+
json: async () => ({
2235+
issuer: 'https://auth.example.com',
2236+
authorization_endpoint: 'https://auth.example.com/authorize',
2237+
token_endpoint: 'https://auth.example.com/token',
2238+
registration_endpoint: 'https://auth.example.com/register',
2239+
response_types_supported: ['code'],
2240+
code_challenge_methods_supported: ['S256']
2241+
})
2242+
});
2243+
} else if (urlString.includes('/register')) {
2244+
return Promise.resolve({
2245+
ok: true,
2246+
status: 200,
2247+
json: async () => ({
2248+
client_id: 'test-client-id',
2249+
client_secret: 'test-client-secret',
2250+
redirect_uris: ['http://localhost:3000/callback'],
2251+
client_name: 'Test Client'
2252+
})
2253+
});
2254+
}
2255+
2256+
return Promise.resolve({ ok: false, status: 404 });
2257+
});
2258+
2259+
// Mock provider methods - no scope in clientMetadata
2260+
(mockProvider.clientInformation as Mock).mockResolvedValue(undefined);
2261+
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
2262+
mockProvider.saveClientInformation = vi.fn();
2263+
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
2264+
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);
2265+
2266+
// Call auth without scope parameter
2267+
const result = await auth(mockProvider, {
2268+
serverUrl: 'https://api.example.com/'
2269+
});
2270+
2271+
expect(result).toBe('REDIRECT');
2272+
2273+
// Verify the authorization URL includes the scopes from PRM
2274+
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
2275+
const authUrl: URL = redirectCall[0];
2276+
expect(authUrl.searchParams.get('scope')).toBe('mcp:read mcp:write mcp:admin');
2277+
});
2278+
2279+
it('prefers explicit scope parameter over scopes_supported from PRM', async () => {
2280+
// Mock PRM with scopes_supported
2281+
mockFetch.mockImplementation(url => {
2282+
const urlString = url.toString();
2283+
2284+
if (urlString.includes('/.well-known/oauth-protected-resource')) {
2285+
return Promise.resolve({
2286+
ok: true,
2287+
status: 200,
2288+
json: async () => ({
2289+
resource: 'https://api.example.com/',
2290+
authorization_servers: ['https://auth.example.com'],
2291+
scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin']
2292+
})
2293+
});
2294+
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
2295+
return Promise.resolve({
2296+
ok: true,
2297+
status: 200,
2298+
json: async () => ({
2299+
issuer: 'https://auth.example.com',
2300+
authorization_endpoint: 'https://auth.example.com/authorize',
2301+
token_endpoint: 'https://auth.example.com/token',
2302+
registration_endpoint: 'https://auth.example.com/register',
2303+
response_types_supported: ['code'],
2304+
code_challenge_methods_supported: ['S256']
2305+
})
2306+
});
2307+
} else if (urlString.includes('/register')) {
2308+
return Promise.resolve({
2309+
ok: true,
2310+
status: 200,
2311+
json: async () => ({
2312+
client_id: 'test-client-id',
2313+
client_secret: 'test-client-secret',
2314+
redirect_uris: ['http://localhost:3000/callback'],
2315+
client_name: 'Test Client'
2316+
})
2317+
});
2318+
}
2319+
2320+
return Promise.resolve({ ok: false, status: 404 });
2321+
});
2322+
2323+
// Mock provider methods
2324+
(mockProvider.clientInformation as Mock).mockResolvedValue(undefined);
2325+
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
2326+
mockProvider.saveClientInformation = vi.fn();
2327+
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
2328+
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);
2329+
2330+
// Call auth with explicit scope parameter
2331+
const result = await auth(mockProvider, {
2332+
serverUrl: 'https://api.example.com/',
2333+
scope: 'mcp:read'
2334+
});
2335+
2336+
expect(result).toBe('REDIRECT');
2337+
2338+
// Verify the authorization URL uses the explicit scope, not scopes_supported
2339+
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
2340+
const authUrl: URL = redirectCall[0];
2341+
expect(authUrl.searchParams.get('scope')).toBe('mcp:read');
2342+
});
2343+
22152344
it('fetches AS metadata with path from serverUrl when PRM returns external AS', async () => {
22162345
// Mock PRM discovery that returns an external AS
22172346
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)