simpleOAuthClient.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. #!/usr/bin/env node
  2. import { createServer } from 'node:http';
  3. import { createInterface } from 'node:readline';
  4. import { URL } from 'node:url';
  5. import { Client } from '../../client/index.js';
  6. import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js';
  7. import { CallToolResultSchema, ListToolsResultSchema } from '../../types.js';
  8. import { UnauthorizedError } from '../../client/auth.js';
  9. import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js';
  10. // Configuration
  11. const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp';
  12. const CALLBACK_PORT = 8090; // Use different port than auth server (3001)
  13. const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`;
  14. /**
  15. * Interactive MCP client with OAuth authentication
  16. * Demonstrates the complete OAuth flow with browser-based authorization
  17. */
  18. class InteractiveOAuthClient {
  19. constructor(serverUrl, clientMetadataUrl) {
  20. this.serverUrl = serverUrl;
  21. this.clientMetadataUrl = clientMetadataUrl;
  22. this.client = null;
  23. this.rl = createInterface({
  24. input: process.stdin,
  25. output: process.stdout
  26. });
  27. }
  28. /**
  29. * Prompts user for input via readline
  30. */
  31. async question(query) {
  32. return new Promise(resolve => {
  33. this.rl.question(query, resolve);
  34. });
  35. }
  36. /**
  37. * Example OAuth callback handler - in production, use a more robust approach
  38. * for handling callbacks and storing tokens
  39. */
  40. /**
  41. * Starts a temporary HTTP server to receive the OAuth callback
  42. */
  43. async waitForOAuthCallback() {
  44. return new Promise((resolve, reject) => {
  45. const server = createServer((req, res) => {
  46. // Ignore favicon requests
  47. if (req.url === '/favicon.ico') {
  48. res.writeHead(404);
  49. res.end();
  50. return;
  51. }
  52. console.log(`📥 Received callback: ${req.url}`);
  53. const parsedUrl = new URL(req.url || '', 'http://localhost');
  54. const code = parsedUrl.searchParams.get('code');
  55. const error = parsedUrl.searchParams.get('error');
  56. if (code) {
  57. console.log(`✅ Authorization code received: ${code?.substring(0, 10)}...`);
  58. res.writeHead(200, { 'Content-Type': 'text/html' });
  59. res.end(`
  60. <html>
  61. <body>
  62. <h1>Authorization Successful!</h1>
  63. <p>You can close this window and return to the terminal.</p>
  64. <script>setTimeout(() => window.close(), 2000);</script>
  65. </body>
  66. </html>
  67. `);
  68. resolve(code);
  69. setTimeout(() => server.close(), 3000);
  70. }
  71. else if (error) {
  72. console.log(`❌ Authorization error: ${error}`);
  73. res.writeHead(400, { 'Content-Type': 'text/html' });
  74. res.end(`
  75. <html>
  76. <body>
  77. <h1>Authorization Failed</h1>
  78. <p>Error: ${error}</p>
  79. </body>
  80. </html>
  81. `);
  82. reject(new Error(`OAuth authorization failed: ${error}`));
  83. }
  84. else {
  85. console.log(`❌ No authorization code or error in callback`);
  86. res.writeHead(400);
  87. res.end('Bad request');
  88. reject(new Error('No authorization code provided'));
  89. }
  90. });
  91. server.listen(CALLBACK_PORT, () => {
  92. console.log(`OAuth callback server started on http://localhost:${CALLBACK_PORT}`);
  93. });
  94. });
  95. }
  96. async attemptConnection(oauthProvider) {
  97. console.log('🚢 Creating transport with OAuth provider...');
  98. const baseUrl = new URL(this.serverUrl);
  99. const transport = new StreamableHTTPClientTransport(baseUrl, {
  100. authProvider: oauthProvider
  101. });
  102. console.log('🚢 Transport created');
  103. try {
  104. console.log('🔌 Attempting connection (this will trigger OAuth redirect)...');
  105. await this.client.connect(transport);
  106. console.log('✅ Connected successfully');
  107. }
  108. catch (error) {
  109. if (error instanceof UnauthorizedError) {
  110. console.log('🔐 OAuth required - waiting for authorization...');
  111. const callbackPromise = this.waitForOAuthCallback();
  112. const authCode = await callbackPromise;
  113. await transport.finishAuth(authCode);
  114. console.log('🔐 Authorization code received:', authCode);
  115. console.log('🔌 Reconnecting with authenticated transport...');
  116. await this.attemptConnection(oauthProvider);
  117. }
  118. else {
  119. console.error('❌ Connection failed with non-auth error:', error);
  120. throw error;
  121. }
  122. }
  123. }
  124. /**
  125. * Establishes connection to the MCP server with OAuth authentication
  126. */
  127. async connect() {
  128. console.log(`🔗 Attempting to connect to ${this.serverUrl}...`);
  129. const clientMetadata = {
  130. client_name: 'Simple OAuth MCP Client',
  131. redirect_uris: [CALLBACK_URL],
  132. grant_types: ['authorization_code', 'refresh_token'],
  133. response_types: ['code'],
  134. token_endpoint_auth_method: 'client_secret_post'
  135. };
  136. console.log('🔐 Creating OAuth provider...');
  137. const oauthProvider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, (redirectUrl) => {
  138. console.log(`\n🔗 Please open this URL in your browser to authorize:\n ${redirectUrl.toString()}`);
  139. }, this.clientMetadataUrl);
  140. console.log('🔐 OAuth provider created');
  141. console.log('👤 Creating MCP client...');
  142. this.client = new Client({
  143. name: 'simple-oauth-client',
  144. version: '1.0.0'
  145. }, { capabilities: {} });
  146. console.log('👤 Client created');
  147. console.log('🔐 Starting OAuth flow...');
  148. await this.attemptConnection(oauthProvider);
  149. // Start interactive loop
  150. await this.interactiveLoop();
  151. }
  152. /**
  153. * Main interactive loop for user commands
  154. */
  155. async interactiveLoop() {
  156. console.log('\n🎯 Interactive MCP Client with OAuth');
  157. console.log('Commands:');
  158. console.log(' list - List available tools');
  159. console.log(' call <tool_name> [args] - Call a tool');
  160. console.log(' stream <tool_name> [args] - Call a tool with streaming (shows task status)');
  161. console.log(' quit - Exit the client');
  162. console.log();
  163. while (true) {
  164. try {
  165. const command = await this.question('mcp> ');
  166. if (!command.trim()) {
  167. continue;
  168. }
  169. if (command === 'quit') {
  170. console.log('\n👋 Goodbye!');
  171. this.close();
  172. process.exit(0);
  173. }
  174. else if (command === 'list') {
  175. await this.listTools();
  176. }
  177. else if (command.startsWith('call ')) {
  178. await this.handleCallTool(command);
  179. }
  180. else if (command.startsWith('stream ')) {
  181. await this.handleStreamTool(command);
  182. }
  183. else {
  184. console.log("❌ Unknown command. Try 'list', 'call <tool_name>', 'stream <tool_name>', or 'quit'");
  185. }
  186. }
  187. catch (error) {
  188. if (error instanceof Error && error.message === 'SIGINT') {
  189. console.log('\n\n👋 Goodbye!');
  190. break;
  191. }
  192. console.error('❌ Error:', error);
  193. }
  194. }
  195. }
  196. async listTools() {
  197. if (!this.client) {
  198. console.log('❌ Not connected to server');
  199. return;
  200. }
  201. try {
  202. const request = {
  203. method: 'tools/list',
  204. params: {}
  205. };
  206. const result = await this.client.request(request, ListToolsResultSchema);
  207. if (result.tools && result.tools.length > 0) {
  208. console.log('\n📋 Available tools:');
  209. result.tools.forEach((tool, index) => {
  210. console.log(`${index + 1}. ${tool.name}`);
  211. if (tool.description) {
  212. console.log(` Description: ${tool.description}`);
  213. }
  214. console.log();
  215. });
  216. }
  217. else {
  218. console.log('No tools available');
  219. }
  220. }
  221. catch (error) {
  222. console.error('❌ Failed to list tools:', error);
  223. }
  224. }
  225. async handleCallTool(command) {
  226. const parts = command.split(/\s+/);
  227. const toolName = parts[1];
  228. if (!toolName) {
  229. console.log('❌ Please specify a tool name');
  230. return;
  231. }
  232. // Parse arguments (simple JSON-like format)
  233. let toolArgs = {};
  234. if (parts.length > 2) {
  235. const argsString = parts.slice(2).join(' ');
  236. try {
  237. toolArgs = JSON.parse(argsString);
  238. }
  239. catch {
  240. console.log('❌ Invalid arguments format (expected JSON)');
  241. return;
  242. }
  243. }
  244. await this.callTool(toolName, toolArgs);
  245. }
  246. async callTool(toolName, toolArgs) {
  247. if (!this.client) {
  248. console.log('❌ Not connected to server');
  249. return;
  250. }
  251. try {
  252. const request = {
  253. method: 'tools/call',
  254. params: {
  255. name: toolName,
  256. arguments: toolArgs
  257. }
  258. };
  259. const result = await this.client.request(request, CallToolResultSchema);
  260. console.log(`\n🔧 Tool '${toolName}' result:`);
  261. if (result.content) {
  262. result.content.forEach(content => {
  263. if (content.type === 'text') {
  264. console.log(content.text);
  265. }
  266. else {
  267. console.log(content);
  268. }
  269. });
  270. }
  271. else {
  272. console.log(result);
  273. }
  274. }
  275. catch (error) {
  276. console.error(`❌ Failed to call tool '${toolName}':`, error);
  277. }
  278. }
  279. async handleStreamTool(command) {
  280. const parts = command.split(/\s+/);
  281. const toolName = parts[1];
  282. if (!toolName) {
  283. console.log('❌ Please specify a tool name');
  284. return;
  285. }
  286. // Parse arguments (simple JSON-like format)
  287. let toolArgs = {};
  288. if (parts.length > 2) {
  289. const argsString = parts.slice(2).join(' ');
  290. try {
  291. toolArgs = JSON.parse(argsString);
  292. }
  293. catch {
  294. console.log('❌ Invalid arguments format (expected JSON)');
  295. return;
  296. }
  297. }
  298. await this.streamTool(toolName, toolArgs);
  299. }
  300. async streamTool(toolName, toolArgs) {
  301. if (!this.client) {
  302. console.log('❌ Not connected to server');
  303. return;
  304. }
  305. try {
  306. // Using the experimental tasks API - WARNING: may change without notice
  307. console.log(`\n🔧 Streaming tool '${toolName}'...`);
  308. const stream = this.client.experimental.tasks.callToolStream({
  309. name: toolName,
  310. arguments: toolArgs
  311. }, CallToolResultSchema, {
  312. task: {
  313. taskId: `task-${Date.now()}`,
  314. ttl: 60000
  315. }
  316. });
  317. // Iterate through all messages yielded by the generator
  318. for await (const message of stream) {
  319. switch (message.type) {
  320. case 'taskCreated':
  321. console.log(`✓ Task created: ${message.task.taskId}`);
  322. break;
  323. case 'taskStatus':
  324. console.log(`⟳ Status: ${message.task.status}`);
  325. if (message.task.statusMessage) {
  326. console.log(` ${message.task.statusMessage}`);
  327. }
  328. break;
  329. case 'result':
  330. console.log('✓ Completed!');
  331. message.result.content.forEach(content => {
  332. if (content.type === 'text') {
  333. console.log(content.text);
  334. }
  335. else {
  336. console.log(content);
  337. }
  338. });
  339. break;
  340. case 'error':
  341. console.log('✗ Error:');
  342. console.log(` ${message.error.message}`);
  343. break;
  344. }
  345. }
  346. }
  347. catch (error) {
  348. console.error(`❌ Failed to stream tool '${toolName}':`, error);
  349. }
  350. }
  351. close() {
  352. this.rl.close();
  353. if (this.client) {
  354. // Note: Client doesn't have a close method in the current implementation
  355. // This would typically close the transport connection
  356. }
  357. }
  358. }
  359. /**
  360. * Main entry point
  361. */
  362. async function main() {
  363. const args = process.argv.slice(2);
  364. const serverUrl = args[0] || DEFAULT_SERVER_URL;
  365. const clientMetadataUrl = args[1];
  366. console.log('🚀 Simple MCP OAuth Client');
  367. console.log(`Connecting to: ${serverUrl}`);
  368. if (clientMetadataUrl) {
  369. console.log(`Client Metadata URL: ${clientMetadataUrl}`);
  370. }
  371. console.log();
  372. const client = new InteractiveOAuthClient(serverUrl, clientMetadataUrl);
  373. // Handle graceful shutdown
  374. process.on('SIGINT', () => {
  375. console.log('\n\n👋 Goodbye!');
  376. client.close();
  377. process.exit(0);
  378. });
  379. try {
  380. await client.connect();
  381. }
  382. catch (error) {
  383. console.error('Failed to start client:', error);
  384. process.exit(1);
  385. }
  386. finally {
  387. client.close();
  388. }
  389. }
  390. // Run if this file is executed directly
  391. main().catch(error => {
  392. console.error('Unhandled error:', error);
  393. process.exit(1);
  394. });
  395. //# sourceMappingURL=simpleOAuthClient.js.map