Fix PKCE implementation, SSE parsing, error handling, and usage clarity. Add --help flag and improve output messaging for both examples.
429 lines
No EOL
13 KiB
JavaScript
Executable file
429 lines
No EOL
13 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
||
|
||
/**
|
||
* Simple Node.js MCP Client Example - Manual Implementation
|
||
*
|
||
* This demonstrates how to interact with the MCP server programmatically
|
||
* WITHOUT using the MCP SDK client. This is for educational purposes to
|
||
* show the underlying protocol mechanics.
|
||
*
|
||
* In production, you would use the MCP SDK client which handles:
|
||
* - SSE (Server-Sent Events) parsing
|
||
* - Session management and reconnection logic
|
||
* - Request/response correlation
|
||
* - Error handling and retries
|
||
*
|
||
* For SDK usage, see:
|
||
* @modelcontextprotocol/sdk/client/streamableHttp.js
|
||
*
|
||
* Prerequisites:
|
||
* - Both servers running: npm run dev
|
||
* - Node.js installed
|
||
*
|
||
* Usage:
|
||
* node client.js
|
||
*/
|
||
|
||
const http = require('http');
|
||
const https = require('https');
|
||
const { URL } = require('url');
|
||
const readline = require('readline');
|
||
const crypto = require('crypto');
|
||
|
||
// Configuration
|
||
const AUTH_SERVER = 'http://localhost:3001';
|
||
const MCP_SERVER = 'http://localhost:3232';
|
||
const REDIRECT_URI = 'http://localhost:8080/callback';
|
||
|
||
// Colors for console output
|
||
const colors = {
|
||
reset: '\x1b[0m',
|
||
bright: '\x1b[1m',
|
||
red: '\x1b[31m',
|
||
green: '\x1b[32m',
|
||
yellow: '\x1b[33m',
|
||
blue: '\x1b[34m',
|
||
magenta: '\x1b[35m',
|
||
cyan: '\x1b[36m'
|
||
};
|
||
|
||
// Utility functions
|
||
const log = {
|
||
info: (msg) => console.log(`${colors.blue}ℹ${colors.reset} ${msg}`),
|
||
success: (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`),
|
||
error: (msg) => console.log(`${colors.red}✗${colors.reset} ${msg}`),
|
||
section: (msg) => console.log(`\n${colors.bright}${colors.blue}=== ${msg} ===${colors.reset}`)
|
||
};
|
||
|
||
/**
|
||
* Simple HTTP request helper
|
||
*/
|
||
function makeRequest(url, options = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
const parsedUrl = new URL(url);
|
||
const client = parsedUrl.protocol === 'https:' ? https : http;
|
||
|
||
const reqOptions = {
|
||
hostname: parsedUrl.hostname,
|
||
port: parsedUrl.port,
|
||
path: parsedUrl.pathname + parsedUrl.search,
|
||
method: options.method || 'GET',
|
||
headers: options.headers || {}
|
||
};
|
||
|
||
const req = client.request(reqOptions, (res) => {
|
||
let data = '';
|
||
res.on('data', chunk => data += chunk);
|
||
res.on('end', () => {
|
||
// Check if this is an SSE response
|
||
if (data.startsWith('event:') || data.includes('\ndata: ')) {
|
||
// Parse SSE format
|
||
const lines = data.split('\n');
|
||
for (const line of lines) {
|
||
if (line.startsWith('data: ')) {
|
||
try {
|
||
const json = JSON.parse(line.substring(6));
|
||
resolve({ status: res.statusCode, headers: res.headers, data: json });
|
||
return;
|
||
} catch {
|
||
// Continue to next line
|
||
}
|
||
}
|
||
}
|
||
// If no JSON found in SSE, return raw data
|
||
resolve({ status: res.statusCode, headers: res.headers, data });
|
||
} else {
|
||
// Try to parse as regular JSON
|
||
try {
|
||
const json = JSON.parse(data);
|
||
resolve({ status: res.statusCode, headers: res.headers, data: json });
|
||
} catch {
|
||
resolve({ status: res.statusCode, headers: res.headers, data });
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
req.on('error', reject);
|
||
|
||
if (options.body) {
|
||
req.write(options.body);
|
||
}
|
||
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Step 1: Register OAuth client
|
||
*/
|
||
async function registerClient() {
|
||
log.section('Registering OAuth Client');
|
||
|
||
try {
|
||
const response = await makeRequest(`${AUTH_SERVER}/register`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
client_name: 'node-example-client',
|
||
redirect_uris: [REDIRECT_URI]
|
||
})
|
||
});
|
||
|
||
if (response.status === 200 || response.status === 201) {
|
||
log.success('Client registered successfully');
|
||
return {
|
||
clientId: response.data.client_id,
|
||
clientSecret: response.data.client_secret
|
||
};
|
||
} else {
|
||
throw new Error(`Registration failed (HTTP ${response.status}): ${JSON.stringify(response.data)}`);
|
||
}
|
||
} catch (error) {
|
||
if (error.code === 'ECONNREFUSED') {
|
||
throw new Error(`Cannot connect to auth server at ${AUTH_SERVER}. Make sure it's running (npm run dev).`);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Step 2: Simple OAuth flow (manual for demonstration)
|
||
* In production, use a proper OAuth library
|
||
*/
|
||
async function performOAuthFlow(clientId, clientSecret) {
|
||
log.section('OAuth Authorization Flow');
|
||
|
||
log.info('For this demo, we need you to manually complete the OAuth flow.');
|
||
log.info('In a production app, this would be automated.\n');
|
||
|
||
// Generate PKCE challenge (proper S256 implementation)
|
||
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
||
const codeChallenge = crypto
|
||
.createHash('sha256')
|
||
.update(codeVerifier)
|
||
.digest('base64url');
|
||
|
||
const authUrl = `${AUTH_SERVER}/authorize?` +
|
||
`client_id=${clientId}&` +
|
||
`redirect_uri=${encodeURIComponent(REDIRECT_URI)}&` +
|
||
`response_type=code&` +
|
||
`code_challenge=${codeChallenge}&` +
|
||
`code_challenge_method=S256&` +
|
||
`state=demo-state`;
|
||
|
||
console.log('1. Open this URL in your browser:');
|
||
console.log(` ${colors.cyan}${authUrl}${colors.reset}\n`);
|
||
|
||
console.log('2. Complete the authentication flow');
|
||
console.log('3. You\'ll be redirected to a URL like:');
|
||
console.log(` ${REDIRECT_URI}?code=AUTHORIZATION_CODE&state=demo-state`);
|
||
console.log(` ${colors.yellow}(You'll see "site can't be reached" - this is expected!)${colors.reset}\n`);
|
||
|
||
console.log('4. Copy the authorization code from the URL in your browser\'s address bar');
|
||
console.log(' The code is the long string after "code=" and before "&state="\n');
|
||
|
||
// Get authorization code from user
|
||
const rl = readline.createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout
|
||
});
|
||
|
||
return new Promise((resolve) => {
|
||
rl.question('5. Paste the AUTHORIZATION_CODE here: ', async (code) => {
|
||
rl.close();
|
||
|
||
// Exchange code for token
|
||
log.info('Exchanging authorization code for access token...');
|
||
|
||
const tokenResponse = await makeRequest(`${AUTH_SERVER}/token`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: `grant_type=authorization_code&` +
|
||
`code=${code}&` +
|
||
`redirect_uri=${encodeURIComponent(REDIRECT_URI)}&` +
|
||
`client_id=${clientId}&` +
|
||
`client_secret=${clientSecret}&` +
|
||
`code_verifier=${codeVerifier}`
|
||
});
|
||
|
||
if (tokenResponse.data.access_token) {
|
||
log.success('Access token obtained!');
|
||
resolve(tokenResponse.data.access_token);
|
||
} else {
|
||
throw new Error(`Token exchange failed: ${JSON.stringify(tokenResponse.data)}`);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Step 3: Initialize MCP session
|
||
*/
|
||
async function initializeMCPSession(accessToken) {
|
||
log.section('Initializing MCP Session');
|
||
|
||
const response = await makeRequest(`${MCP_SERVER}/mcp`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${accessToken}`,
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json, text/event-stream'
|
||
},
|
||
body: JSON.stringify({
|
||
jsonrpc: '2.0',
|
||
id: 'init',
|
||
method: 'initialize',
|
||
params: {
|
||
protocolVersion: '2024-11-05',
|
||
capabilities: {},
|
||
clientInfo: {
|
||
name: 'node-example',
|
||
version: '1.0'
|
||
}
|
||
}
|
||
})
|
||
});
|
||
|
||
if (response.data.result) {
|
||
log.success('MCP session initialized');
|
||
const sessionId = response.headers['mcp-session-id'];
|
||
if (!sessionId) {
|
||
throw new Error('No session ID received in headers');
|
||
}
|
||
return sessionId;
|
||
} else if (response.data.error) {
|
||
throw new Error(`Initialization failed: ${response.data.error.message}`);
|
||
} else {
|
||
throw new Error(`Initialization failed: ${JSON.stringify(response.data)}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Step 4: Demonstrate MCP features
|
||
*/
|
||
async function demonstrateMCPFeatures(accessToken, sessionId) {
|
||
log.section('Demonstrating MCP Features');
|
||
|
||
// List available tools
|
||
log.info('Fetching available tools...');
|
||
const toolsResponse = await makeRequest(`${MCP_SERVER}/mcp`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${accessToken}`,
|
||
'Mcp-Session-Id': sessionId,
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json, text/event-stream'
|
||
},
|
||
body: JSON.stringify({
|
||
jsonrpc: '2.0',
|
||
id: 'list-tools',
|
||
method: 'tools/list'
|
||
})
|
||
});
|
||
|
||
if (toolsResponse.data.result && toolsResponse.data.result.tools) {
|
||
console.log(`Found ${toolsResponse.data.result.tools.length} tools:`);
|
||
toolsResponse.data.result.tools.forEach(tool => {
|
||
console.log(` - ${colors.cyan}${tool.name}${colors.reset}: ${tool.description}`);
|
||
});
|
||
} else if (toolsResponse.data.error) {
|
||
log.error(`Failed to list tools: ${toolsResponse.data.error.message}`);
|
||
return;
|
||
}
|
||
|
||
// Call the echo tool
|
||
log.info('\nCalling echo tool...');
|
||
const echoResponse = await makeRequest(`${MCP_SERVER}/mcp`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${accessToken}`,
|
||
'Mcp-Session-Id': sessionId,
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json, text/event-stream'
|
||
},
|
||
body: JSON.stringify({
|
||
jsonrpc: '2.0',
|
||
id: 'echo-1',
|
||
method: 'tools/call',
|
||
params: {
|
||
name: 'echo',
|
||
arguments: {
|
||
message: 'Hello from Node.js client!'
|
||
}
|
||
}
|
||
})
|
||
});
|
||
|
||
if (echoResponse.data.result) {
|
||
console.log('Echo response:', echoResponse.data.result.content);
|
||
} else if (echoResponse.data.error) {
|
||
log.error(`Echo tool failed: ${echoResponse.data.error.message}`);
|
||
}
|
||
|
||
// Call the add tool
|
||
log.info('\nCalling add tool (5 + 3)...');
|
||
const addResponse = await makeRequest(`${MCP_SERVER}/mcp`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${accessToken}`,
|
||
'Mcp-Session-Id': sessionId,
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json, text/event-stream'
|
||
},
|
||
body: JSON.stringify({
|
||
jsonrpc: '2.0',
|
||
id: 'add-1',
|
||
method: 'tools/call',
|
||
params: {
|
||
name: 'add',
|
||
arguments: { a: 5, b: 3 }
|
||
}
|
||
})
|
||
});
|
||
|
||
if (addResponse.data.result) {
|
||
console.log('Add result:', addResponse.data.result.content);
|
||
} else if (addResponse.data.error) {
|
||
log.error(`Add tool failed: ${addResponse.data.error.message}`);
|
||
}
|
||
|
||
// List resources
|
||
log.info('\nFetching available resources...');
|
||
const resourcesResponse = await makeRequest(`${MCP_SERVER}/mcp`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${accessToken}`,
|
||
'Mcp-Session-Id': sessionId,
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json, text/event-stream'
|
||
},
|
||
body: JSON.stringify({
|
||
jsonrpc: '2.0',
|
||
id: 'list-resources',
|
||
method: 'resources/list'
|
||
})
|
||
});
|
||
|
||
if (resourcesResponse.data.result && resourcesResponse.data.result.resources) {
|
||
console.log(`Found ${resourcesResponse.data.result.resources.length} resources (showing first 5):`);
|
||
resourcesResponse.data.result.resources.slice(0, 5).forEach(resource => {
|
||
console.log(` - ${colors.cyan}${resource.uri}${colors.reset}: ${resource.name}`);
|
||
});
|
||
} else if (resourcesResponse.data.error) {
|
||
log.error(`Failed to list resources: ${resourcesResponse.data.error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Main function
|
||
*/
|
||
async function main() {
|
||
console.log(`${colors.bright}${colors.blue}MCP Node.js Client Example${colors.reset}`);
|
||
console.log('==========================\n');
|
||
|
||
try {
|
||
// Step 1: Register client
|
||
const { clientId, clientSecret } = await registerClient();
|
||
console.log(`Client ID: ${clientId}`);
|
||
console.log(`Client Secret: ${clientSecret}\n`);
|
||
|
||
// Step 2: OAuth flow
|
||
const accessToken = await performOAuthFlow(clientId, clientSecret);
|
||
|
||
// Step 3: Initialize MCP session
|
||
const sessionId = await initializeMCPSession(accessToken);
|
||
|
||
// Step 4: Demonstrate features
|
||
await demonstrateMCPFeatures(accessToken, sessionId);
|
||
|
||
log.success('\nExample completed successfully!');
|
||
|
||
// Show how to use curl-examples.sh as an alternative
|
||
console.log(`\n${colors.bright}${colors.cyan}Try curl-examples.sh (alternative to this script):${colors.reset}`);
|
||
console.log('─'.repeat(50));
|
||
console.log(`\n${colors.green}Your credentials:${colors.reset}`);
|
||
console.log(` Access Token: ${colors.yellow}${accessToken}${colors.reset}`);
|
||
console.log(` Session ID: ${colors.yellow}${sessionId}${colors.reset}`);
|
||
console.log(`\n${colors.cyan}Create a new session with curl:${colors.reset}`);
|
||
console.log(` ./examples/curl-examples.sh ${accessToken}`);
|
||
console.log(`\n${colors.cyan}Reuse this session with curl:${colors.reset}`);
|
||
console.log(` ./examples/curl-examples.sh ${accessToken} ${sessionId}\n`);
|
||
|
||
} catch (error) {
|
||
if (error.code === 'ECONNREFUSED') {
|
||
log.error('Connection refused - Is the auth server running?');
|
||
log.error(`Could not connect to ${error.address || 'server'}:${error.port || 'unknown'}`);
|
||
console.log('\nMake sure to start the servers first:');
|
||
console.log(' npm run dev');
|
||
} else if (error.message) {
|
||
log.error(`Error: ${error.message}`);
|
||
} else {
|
||
log.error(`Error: ${JSON.stringify(error)}`);
|
||
}
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// Run the example
|
||
main(); |