diff --git a/examples/code/home/mcp/remote-code-client/PublicOAuthClient.ts.fake b/examples/code/home/mcp/remote-code-client/PublicOAuthClient.ts.fake new file mode 100644 index 00000000..7b35e4ee --- /dev/null +++ b/examples/code/home/mcp/remote-code-client/PublicOAuthClient.ts.fake @@ -0,0 +1,196 @@ +import { + OAuthClientProvider +} from '@modelcontextprotocol/sdk/client/auth.js'; + +import open from 'open'; + +import type { + OAuthClientMetadata, + OAuthClientInformation, + OAuthTokens, + OAuthClientInformationFull +} from '@modelcontextprotocol/sdk/shared/auth.js'; + +// Create a simple localStorage polyfill for Node.js environment +class NodeStorage implements Storage { + private data: Record = {}; + + get length(): number { + return Object.keys(this.data).length; + } + + clear(): void { + this.data = {}; + } + + getItem(key: string): string | null { + return key in this.data ? this.data[key] : null; + } + + key(index: number): string | null { + return Object.keys(this.data)[index] || null; + } + + removeItem(key: string): void { + delete this.data[key]; + } + + setItem(key: string, value: string): void { + this.data[key] = value; + } +} + +// Determine if we're in a browser or Node.js environment +const isNodeEnv = typeof window === 'undefined' || typeof localStorage === 'undefined'; +const storageImplementation = isNodeEnv ? new NodeStorage() : localStorage; + +/** + * An implementation of OAuthClientProvider that works with standard OAuth 2.0 servers. + * This implementation uses localStorage for persisting tokens and client information. + */ +export class PublicOAuthClient implements OAuthClientProvider { + private storage: Storage; + private readonly clientMetadataValue: OAuthClientMetadata; + private readonly redirectUrlValue: string | URL; + private readonly storageKeyPrefix: string; + private readonly clientId: string; + + /** + * Creates a new PublicOAuthClient + * + * @param client_id The OAuth client ID + * @param clientMetadata The OAuth client metadata + * @param redirectUrl The URL to redirect to after authorization + * @param storageKeyPrefix Prefix for localStorage keys (default: 'mcp_oauth_') + * @param storage Storage implementation (default: storageImplementation) + */ + constructor( + clientMetadata: OAuthClientMetadata, + client_id: string, + redirectUrl: string | URL, + storageKeyPrefix = 'mcp_oauth_', + storage = storageImplementation + ) { + this.clientId = client_id; + this.clientMetadataValue = clientMetadata; + this.redirectUrlValue = redirectUrl; + this.storageKeyPrefix = storageKeyPrefix; + this.storage = storage; + } + + /** + * The URL to redirect the user agent to after authorization. + */ + get redirectUrl(): string | URL { + return this.redirectUrlValue; + } + + /** + * Metadata about this OAuth client. + */ + get clientMetadata(): OAuthClientMetadata { + return this.clientMetadataValue; + } + + /** + * Loads information about this OAuth client from storage + */ + clientInformation(): OAuthClientInformation | undefined { + const clientInfoStr = this.storage.getItem(`${this.storageKeyPrefix}client_info`); + if (!clientInfoStr) { + // Return basic client information with client_id if nothing in storage + return { + client_id: this.clientId + }; + } + + try { + return JSON.parse(clientInfoStr) as OAuthClientInformation; + } catch (e) { + console.error('Failed to parse client information', e); + return undefined; + } + } + + /** + * Saves client information to storage + */ + saveClientInformation(clientInformation: OAuthClientInformationFull): void { + this.storage.setItem( + `${this.storageKeyPrefix}client_info`, + JSON.stringify(clientInformation) + ); + } + + /** + * Loads any existing OAuth tokens for the current session + */ + tokens(): OAuthTokens | undefined { + const tokensStr = this.storage.getItem(`${this.storageKeyPrefix}tokens`); + if (!tokensStr) { + return undefined; + } + + try { + return JSON.parse(tokensStr) as OAuthTokens; + } catch (e) { + console.error('Failed to parse tokens', e); + return undefined; + } + } + + /** + * Stores new OAuth tokens for the current session + */ + saveTokens(tokens: OAuthTokens): void { + this.storage.setItem( + `${this.storageKeyPrefix}tokens`, + JSON.stringify(tokens) + ); + } + + /** + * Redirects the user agent to the given authorization URL + */ + redirectToAuthorization(authorizationUrl: URL): void { + // TODO: Update MCP TS SDK to add state + // TODO: Verify state in callback + const state = crypto.randomUUID(); + authorizationUrl.searchParams.set('state', state); + + if (typeof window !== 'undefined') { + window.location.href = authorizationUrl.toString(); + } else { + console.log(`Opening URL: ${authorizationUrl.toString()}`); + open(authorizationUrl.toString()); + } + } + + /** + * Saves a PKCE code verifier for the current session + */ + saveCodeVerifier(codeVerifier: string): void { + console.log("hit saveCodeVerifier"); + this.storage.setItem(`${this.storageKeyPrefix}code_verifier`, codeVerifier); + } + + /** + * Loads the PKCE code verifier for the current session + */ + codeVerifier(): string { + console.log("hit codeVerifier"); + const verifier = this.storage.getItem(`${this.storageKeyPrefix}code_verifier`); + if (!verifier) { + throw new Error('No code verifier found in storage'); + } + return verifier; + } + + /** + * Clears all OAuth-related data from storage + */ + clearAuth(): void { + this.storage.removeItem(`${this.storageKeyPrefix}tokens`); + this.storage.removeItem(`${this.storageKeyPrefix}code_verifier`); + } +} \ No newline at end of file diff --git a/examples/code/home/mcp/remote-code-client/index.ts.fake b/examples/code/home/mcp/remote-code-client/index.ts.fake new file mode 100644 index 00000000..f471dfcb --- /dev/null +++ b/examples/code/home/mcp/remote-code-client/index.ts.fake @@ -0,0 +1,166 @@ + +import express from 'express'; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; +import { PublicOAuthClient } from "./src/PublicOAuthClient.js"; +import { OAuthClientMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; + +async function main() { + // Start an Express server to handle the OAuth callback + const app = express(); + const callbackServer = app.listen(3000); + + // Create a promise to resolve when we get the authorization code + const codePromise = new Promise((resolve) => { + app.get('/callback', (req, res) => { + const code = req.query.code as string; + res.send('Authentication successful! You can close this window.'); + + // Resolve the promise with the authorization code + resolve(code); + }); + }); + + // Set up our MCP client with OAuth support + const serverUrl = "https://api.arcade.dev/v1/mcps/beta/mcp"; + + const clientMetadata: OAuthClientMetadata = { + client_name: "My MCP Client", + redirect_uris: ["http://localhost:3000/callback"], + }; + + const authProvider = new PublicOAuthClient( + clientMetadata, + "mcp_beta", + "http://localhost:3000/callback" + ); + + let transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider, + requestInit: { + headers: { Accept: "application/json" } + } + }); + + const client = new Client({ + name: "example-client", + version: "1.0.0", + }); + console.log("Connecting to MCP..."); + + try { + // This will likely fail with UnauthorizedError + try { + await client.connect(transport); + console.log("Connected without auth (unusual)"); + } catch (error: any) { + if (error instanceof UnauthorizedError) { + console.log("Authentication required, waiting for callback..."); + // Wait for the authorization code from the callback + const code = await codePromise; + + // Complete the authentication with the code + await transport.finishAuth(code); + console.log("Auth complete"); + + // Need to rebuild the transport (to reset it), but the authProvider is persistent + transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider, + requestInit: { + headers: { Accept: "application/json" } + } + }); + + // Now try connecting again + console.log("Connecting to MCP..."); + await client.connect(transport); + console.log("Connected to MCP"); + } else { + throw error; + } + } + + // List available tools + console.log("Listing tools"); + const toolsResult = await client.listTools(); + + console.log(`Available tools (${toolsResult.tools.length} tools):`); + for (const tool of toolsResult.tools) { + const firstLineOfDescription = tool.description?.split("\n")[0]; + console.log(` - ${tool.name} (${firstLineOfDescription})`); + } + + // Call a tool + console.log("Calling tool math_multiply"); + await callTool(client, "math_multiply", { + a: "2", + b: "3", + }); + + // Call another tool + console.log("Calling tool google_listemails"); + await callTool(client, "google_listemails", { + n_emails: 3 + }); + + console.log("Done! Goodbye"); + } catch (error) { + console.error("Error:", error); + } finally { + await client.close(); + callbackServer.close(); + process.exit(0); + } +} + +main().catch(error => { + console.error("Unhandled error:", error); + process.exit(1); +}); + +async function callTool(client: Client, toolName: string, args: any) { + try { + const result = await client.callTool({ + name: toolName, + arguments: args, + }); + + console.log("Tool result:"); + result.content.forEach((item) => { + if (item.type === "text") { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } catch (error: any) { + console.log("Error:", error); + // Check if this is an interaction_required error + if (error.code === -32003 && error.data && error.data.type === "url") { + console.log("\n------------------------------------------"); + console.log(error.data.message.text); + console.log(error.data.url); + console.log("------------------------------------------\n"); + + // Prompt user to press any key after authorization + console.log( + "After completing the authorization flow, press Enter to continue...", + ); + await new Promise((resolve) => { + process.stdin.once("data", () => { + resolve(undefined); + }); + process.stdin.resume(); + }); + + // Retry the tool call + console.log("Retrying tool call after authorization..."); + await callTool(client, toolName, args); + } else { + // Re-throw other errors + throw error; + } + } +} \ No newline at end of file diff --git a/examples/code/home/mcp/streamable-http/typescript-client.ts.fake b/examples/code/home/mcp/streamable-http/typescript-client.ts.fake deleted file mode 100644 index ef2d258d..00000000 --- a/examples/code/home/mcp/streamable-http/typescript-client.ts.fake +++ /dev/null @@ -1,129 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { - CallToolRequest, - CallToolResultSchema, - ListToolsRequest, - ListToolsResultSchema, -} from "@modelcontextprotocol/sdk/types.js"; - -// Replace with your actual API key and user ID -const arcadeApiKey = "your_arcade_api_key"; -const userId = "your_email@example.com"; -const arcadeURL = "https://api.arcade.dev/v1"; -const serverURL = `${arcadeURL}/mcp`; - -// Exchange API key for user-scoped token -const tokenExchangeResponse = await fetch(`${arcadeURL}/tokens/exchange`, { - method: "POST", - headers: { - Authorization: `Bearer ${arcadeApiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ user_id: userId }), -}); - -if (!tokenExchangeResponse.ok) { - const errorText = await tokenExchangeResponse.text(); - console.error("Token exchange failed:", errorText); - process.exit(1); -} - -const tokenData = await tokenExchangeResponse.json(); -const authToken = tokenData.token.trim(); - -// Create an MCP client -const client = new Client({ name: "my-mcp-client", version: "1.0.0" }); -client.onerror = (error) => console.error("Client error:", error); - -// Connect to the Arcade MCP server -const transport = new StreamableHTTPClientTransport(new URL(serverURL), { - requestInit: { - headers: { - Authorization: `Bearer ${authToken}`, - }, - }, -}); - -await client.connect(transport); -console.log("Successfully connected to MCP server."); - -// Get available tools from the server -const toolsRequest: ListToolsRequest = { - method: "tools/list", -}; -const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); - -// Display available tools -console.log(`Available tools (${toolsResult.tools.length} tools):`); -for (const tool of toolsResult.tools) { - const firstLineOfDescription = tool.description?.split("\n")[0]; - console.log(` - ${tool.name} (${firstLineOfDescription})`); -} - -try { - // Prepare the tool call request - const callToolRequest: CallToolRequest = { - method: "tools/call", - params: { - name: "Google_ListEmails", - arguments: { - n_emails: 5, - }, - }, - }; - console.log("Calling tool..."); - - // Call the tool - async function callTool() { - try { - const result = await client.request( - callToolRequest, - CallToolResultSchema, - ); - - console.log("Tool result:"); - result.content.forEach((item) => { - if (item.type === "text") { - console.log(` ${item.text}`); - } else { - console.log(` ${item.type} content:`, item); - } - }); - } catch (error: any) { - // Check if this is an interaction_required error - if (error.code === -32003 && error.data && error.data.type === "url") { - console.log("\n------------------------------------------"); - console.log(error.data.message.text); - console.log(error.data.url); - console.log("------------------------------------------\n"); - - // Prompt user to press any key after authorization - console.log( - "After completing the authorization flow, press Enter to continue...", - ); - await new Promise((resolve) => { - process.stdin.once("data", () => { - resolve(undefined); - }); - process.stdin.resume(); - }); - - console.log("Retrying tool call after authorization..."); - // Retry the tool call - await callTool(); - } else { - // Re-throw other errors - throw error; - } - } - } - - await callTool(); -} catch (error) { - console.log("Error calling tool:", error); -} - -// Finally, close the connection to the MCP server -await transport.close(); -process.exit(0); diff --git a/pages/home/mcp/_meta.ts b/pages/home/mcp/_meta.ts index 7aa48408..fee9ae6a 100644 --- a/pages/home/mcp/_meta.ts +++ b/pages/home/mcp/_meta.ts @@ -1,4 +1,4 @@ export default { "claude-desktop-client": "Arcade with Claude Desktop", - //"remote-code-client": "Arcade as an MCP Server", + "remote-code-client": "Arcade as an MCP Server", }; diff --git a/pages/home/mcp/remote-code-client.mdx b/pages/home/mcp/remote-code-client.mdx new file mode 100644 index 00000000..fac79d54 --- /dev/null +++ b/pages/home/mcp/remote-code-client.mdx @@ -0,0 +1,46 @@ +import { Steps, Tabs } from "nextra/components"; +import { SignupLink } from "@/components/Analytics"; + +# Connect to Arcade's Remote MCP Server + +In this guide, you'll learn how to create a TypeScript client that can connect to Arcade's remote MCP server. + + + +### Prerequisites + +1. Create an Arcade account +1. Get an [Arcade API key](/home/api-keys) and take note, you'll need it in the next steps. + +### Install Dependencies + + + +```bash +npm install @modelcontextprotocol/sdk express open +npm install -D typescript tsx +``` + + +```bash +pnpm add @modelcontextprotocol/sdk express open +pnpm add -D typescript tsx +``` + + + +### Create your Public OAuth Client +1. Create a new file called `PublicOAuthClient.ts` +```ts file=/examples/code/home/mcp/remote-code-client/PublicOAuthClient.ts.fake +``` + +### Create your MCP Client with OAuth support +1. Create a new file called `index.ts` +```ts file=/examples/code/home/mcp/remote-code-client/index.ts.fake +``` + + \ No newline at end of file diff --git a/pages/home/mcp/remote-code-client.txt b/pages/home/mcp/remote-code-client.txt deleted file mode 100644 index e211d659..00000000 --- a/pages/home/mcp/remote-code-client.txt +++ /dev/null @@ -1,77 +0,0 @@ -import { Steps, Tabs } from "nextra/components"; -import { SignupLink } from "@/components/Analytics"; - -# Connect to Arcade's MCP server - -In this guide, you'll learn how to create a TypeScript client that can connect to a remote MCP server. - - - -### Prerequisites - -1. Create an Arcade account -1. Get an [Arcade API key](/home/api-keys) and take note, you'll need it in the next steps. - -### Install Dependencies - -Install the official [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk): -```bash -# Install the MCP SDK -npm install @modelcontextprotocol/sdk -npm install -D typescript tsx -``` - -### Define your constants -Don't forget to replace the `arcadeApiKey` and `userId` with your own values. - -```typescript file=/examples/code/home/mcp/streamable-http/typescript-client.ts.fake#L1-L14 -``` - -### Authentication - -Before connecting to the Arcade MCP server, you first need to exchange your API key for a user-scoped token: - -```typescript file=/examples/code/home/mcp/streamable-http/typescript-client.ts.fake#L16-L33 -``` - -### Connecting to the MCP Server - -Once you have your authentication token, you can connect to the MCP server. The client will automatically begin the initialization flow with the server when `connect()` is called: - -```typescript file=/examples/code/home/mcp/streamable-http/typescript-client.ts.fake#L35-L49 -``` - -### Listing Available Tools - -To discover what tools are available through the MCP server: - -```typescript file=/examples/code/home/mcp/streamable-http/typescript-client.ts.fake#L51-L62 -``` - -### Calling a Tool with Auth Requirements - -Many tools require OAuth authentication. The below snippet shows how to detect when authentication is needed before calling a tool: - -1. Prepare the tool call request -1. Call the tool -1. Handle authorization flow - - If the tool call throws an error with code -32003, then we prompt the user to complete authorization for the tool call before attempting to call the tool again -1. Execute the tool -1. Display the tool result - -This enables seamless integration with services like Google, GitHub, Slack, Microsoft, and others. - -Here's an example of calling the Google_ListEmails tool with proper error handling for authentication: - -```typescript file=/examples/code/home/mcp/streamable-http/typescript-client.ts.fake#L64-L129 -``` - - -```typescript file=/examples/code/home/mcp/streamable-http/typescript-client.ts.fake -``` - -