Implement MCP 2026-07-28 (#2286)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
e4227d135c
commit
1823aae891
1 changed files with 69131 additions and 9001 deletions
426
examples/oauth/simpleOAuthClient.ts
Normal file
426
examples/oauth/simpleOAuthClient.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
import type { ListToolsRequest, OAuthClientMetadata } from '@modelcontextprotocol/client';
|
||||
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
|
||||
import open from 'open';
|
||||
|
||||
import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider';
|
||||
|
||||
// Configuration
|
||||
const DEFAULT_SERVER_URL = 'http://127.0.0.1:3000/mcp';
|
||||
const CALLBACK_PORT = 8090; // Use different port than auth server (3001)
|
||||
const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`;
|
||||
|
||||
/** Minimal HTML escaper for any user/query-derived value interpolated into an HTML response. */
|
||||
function escHtml(s: string): string {
|
||||
return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive MCP client with OAuth authentication
|
||||
* Demonstrates the complete OAuth flow with browser-based authorization
|
||||
*/
|
||||
class InteractiveOAuthClient {
|
||||
private client: Client | null = null;
|
||||
private readonly rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
constructor(
|
||||
private serverUrl: string,
|
||||
private clientMetadataUrl?: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Prompts user for input via readline
|
||||
*/
|
||||
private async question(query: string): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
this.rl.question(query, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the authorization URL in the user's default browser
|
||||
*/
|
||||
private static readonly ALLOWED_SCHEMES = new Set(['http:', 'https:']);
|
||||
|
||||
private async openBrowser(url: string): Promise<void> {
|
||||
console.log(`🌐 Opening browser for authorization: ${url}`);
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!InteractiveOAuthClient.ALLOWED_SCHEMES.has(parsed.protocol)) {
|
||||
console.error(`Refusing to open URL with unsupported scheme '${parsed.protocol}': ${url}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.error(`Invalid URL: ${url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await open(url);
|
||||
} catch {
|
||||
console.log(`Please manually open: ${url}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Example OAuth callback handler - in production, use a more robust approach
|
||||
* for handling callbacks and storing tokens
|
||||
*/
|
||||
/**
|
||||
* Starts a temporary HTTP server to receive the OAuth callback
|
||||
*/
|
||||
private async waitForOAuthCallback(): Promise<URLSearchParams> {
|
||||
return new Promise<URLSearchParams>((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
// Ignore favicon requests
|
||||
if (req.url === '/favicon.ico') {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📥 Received callback: ${req.url}`);
|
||||
const parsedUrl = new URL(req.url || '', 'http://localhost');
|
||||
const code = parsedUrl.searchParams.get('code');
|
||||
const error = parsedUrl.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
console.log(`✅ Authorization code received: ${code?.slice(0, 10)}...`);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Authorization Successful!</h1>
|
||||
<p>You can close this window and return to the terminal.</p>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
// Hand back the whole query — finishAuth() reads `code` + `iss` (RFC 9207) itself.
|
||||
resolve(parsedUrl.searchParams);
|
||||
setTimeout(() => server.close(), 3000);
|
||||
} else if (error) {
|
||||
console.log(`❌ Authorization error: ${error}`);
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>Error: ${escHtml(error)}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
reject(new Error(`OAuth authorization failed: ${error}`));
|
||||
} else {
|
||||
console.log(`❌ No authorization code or error in callback`);
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
reject(new Error('No authorization code provided'));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(CALLBACK_PORT, () => {
|
||||
console.log(`OAuth callback server started on http://localhost:${CALLBACK_PORT}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise<void> {
|
||||
console.log('🚢 Creating transport with OAuth provider...');
|
||||
const baseUrl = new URL(this.serverUrl);
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl, {
|
||||
authProvider: oauthProvider
|
||||
});
|
||||
console.log('🚢 Transport created');
|
||||
|
||||
try {
|
||||
console.log('🔌 Attempting connection (this will trigger OAuth redirect)...');
|
||||
await this.client!.connect(transport);
|
||||
console.log('✅ Connected successfully');
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
console.log('🔐 OAuth required - waiting for authorization...');
|
||||
const callbackParams = await this.waitForOAuthCallback();
|
||||
// Pass the whole callback query — the SDK extracts `code` and validates
|
||||
// `iss` against the recorded issuer (RFC 9207) before exchanging the code.
|
||||
await transport.finishAuth(callbackParams);
|
||||
console.log('🔐 Authorization code received:', callbackParams.get('code'));
|
||||
console.log('🔌 Reconnecting with authenticated transport...');
|
||||
await this.attemptConnection(oauthProvider);
|
||||
} else {
|
||||
console.error('❌ Connection failed with non-auth error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes connection to the MCP server with OAuth authentication
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
console.log(`🔗 Attempting to connect to ${this.serverUrl}...`);
|
||||
|
||||
const clientMetadata: OAuthClientMetadata = {
|
||||
client_name: 'Simple OAuth MCP Client',
|
||||
redirect_uris: [CALLBACK_URL],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
application_type: 'native',
|
||||
token_endpoint_auth_method: 'client_secret_post'
|
||||
};
|
||||
|
||||
console.log('🔐 Creating OAuth provider...');
|
||||
const oauthProvider = new InMemoryOAuthClientProvider(
|
||||
CALLBACK_URL,
|
||||
clientMetadata,
|
||||
(redirectUrl: URL) => {
|
||||
console.log(`📌 OAuth redirect handler called - opening browser`);
|
||||
console.log(`Opening browser to: ${redirectUrl.toString()}`);
|
||||
this.openBrowser(redirectUrl.toString());
|
||||
},
|
||||
this.clientMetadataUrl
|
||||
);
|
||||
console.log('🔐 OAuth provider created');
|
||||
|
||||
console.log('👤 Creating MCP client...');
|
||||
this.client = new Client(
|
||||
{
|
||||
name: 'simple-oauth-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{ capabilities: {} }
|
||||
);
|
||||
console.log('👤 Client created');
|
||||
|
||||
console.log('🔐 Starting OAuth flow...');
|
||||
|
||||
await this.attemptConnection(oauthProvider);
|
||||
|
||||
// Start interactive loop
|
||||
await this.interactiveLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main interactive loop for user commands
|
||||
*/
|
||||
async interactiveLoop(): Promise<void> {
|
||||
console.log('\n🎯 Interactive MCP Client with OAuth');
|
||||
console.log('Commands:');
|
||||
console.log(' list - List available tools');
|
||||
console.log(' call <tool_name> [args] - Call a tool');
|
||||
console.log(' stream <tool_name> [args] - (disabled; returns when the SEP-2663 tasks extension lands)');
|
||||
console.log(' quit - Exit the client');
|
||||
console.log();
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const command = await this.question('mcp> ');
|
||||
|
||||
if (!command.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (command === 'quit') {
|
||||
console.log('\n👋 Goodbye!');
|
||||
this.close();
|
||||
process.exit(0);
|
||||
} else if (command === 'list') {
|
||||
await this.listTools();
|
||||
} else if (command.startsWith('call ')) {
|
||||
await this.handleCallTool(command);
|
||||
} else if (command.startsWith('stream ')) {
|
||||
await this.handleStreamTool(command);
|
||||
} else {
|
||||
console.log("❌ Unknown command. Try 'list', 'call <tool_name>', or 'quit'");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'SIGINT') {
|
||||
console.log('\n\n👋 Goodbye!');
|
||||
break;
|
||||
}
|
||||
console.error('❌ Error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async listTools(): Promise<void> {
|
||||
if (!this.client) {
|
||||
console.log('❌ Not connected to server');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request: ListToolsRequest = {
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
|
||||
const result = await this.client.request(request);
|
||||
|
||||
if (result.tools && result.tools.length > 0) {
|
||||
console.log('\n📋 Available tools:');
|
||||
for (const [index, tool] of result.tools.entries()) {
|
||||
console.log(`${index + 1}. ${tool.name}`);
|
||||
if (tool.description) {
|
||||
console.log(` Description: ${tool.description}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
} else {
|
||||
console.log('No tools available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to list tools:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCallTool(command: string): Promise<void> {
|
||||
const parts = command.split(/\s+/);
|
||||
const toolName = parts[1];
|
||||
|
||||
if (!toolName) {
|
||||
console.log('❌ Please specify a tool name');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse arguments (simple JSON-like format)
|
||||
let toolArgs: Record<string, unknown> = {};
|
||||
if (parts.length > 2) {
|
||||
const argsString = parts.slice(2).join(' ');
|
||||
try {
|
||||
toolArgs = JSON.parse(argsString);
|
||||
} catch {
|
||||
console.log('❌ Invalid arguments format (expected JSON)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.callTool(toolName, toolArgs);
|
||||
}
|
||||
|
||||
private async callTool(toolName: string, toolArgs: Record<string, unknown>): Promise<void> {
|
||||
if (!this.client) {
|
||||
console.log('❌ Not connected to server');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.client.callTool({
|
||||
name: toolName,
|
||||
arguments: toolArgs
|
||||
});
|
||||
|
||||
console.log(`\n🔧 Tool '${toolName}' result:`);
|
||||
if (result.content) {
|
||||
for (const content of result.content) {
|
||||
if (content.type === 'text') {
|
||||
console.log(content.text);
|
||||
} else {
|
||||
console.log(content);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to call tool '${toolName}':`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStreamTool(command: string): Promise<void> {
|
||||
const parts = command.split(/\s+/);
|
||||
const toolName = parts[1];
|
||||
|
||||
if (!toolName) {
|
||||
console.log('❌ Please specify a tool name');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse arguments (simple JSON-like format)
|
||||
let toolArgs: Record<string, unknown> = {};
|
||||
if (parts.length > 2) {
|
||||
const argsString = parts.slice(2).join(' ');
|
||||
try {
|
||||
toolArgs = JSON.parse(argsString);
|
||||
} catch {
|
||||
console.log('❌ Invalid arguments format (expected JSON)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.streamTool(toolName, toolArgs);
|
||||
}
|
||||
|
||||
private async streamTool(toolName: string, toolArgs: Record<string, unknown>): Promise<void> {
|
||||
if (!this.client) {
|
||||
console.log('❌ Not connected to server');
|
||||
return;
|
||||
}
|
||||
|
||||
// The streaming-tool demo (callToolStream) was removed with the 2025-11
|
||||
// experimental tasks (SEP-2663); it returns when the tasks extension lands.
|
||||
void toolName;
|
||||
void toolArgs;
|
||||
console.log('Streaming tool demo removed with the 2025-11 experimental tasks (SEP-2663); returns when the tasks extension lands.');
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.rl.close();
|
||||
if (this.client) {
|
||||
// Note: Client doesn't have a close method in the current implementation
|
||||
// This would typically close the transport connection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const serverUrl = args[0] || DEFAULT_SERVER_URL;
|
||||
const clientMetadataUrl = args[1];
|
||||
|
||||
console.log('🚀 Simple MCP OAuth Client');
|
||||
console.log(`Connecting to: ${serverUrl}`);
|
||||
if (clientMetadataUrl) {
|
||||
console.log(`Client Metadata URL: ${clientMetadataUrl}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
const client = new InteractiveOAuthClient(serverUrl, clientMetadataUrl);
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n\n👋 Goodbye!');
|
||||
client.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (error) {
|
||||
console.error('Failed to start client:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Run if this file is executed directly
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error('Error running client:', error);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
Loading…
Reference in a new issue