HomeMCPArcade as an MCP Server

Connect to Arcade’s Remote 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
  2. Get an Arcade API key and take note, you’ll need it in the next steps.

Install Dependencies

npm install @modelcontextprotocol/sdk express open
npm install -D typescript tsx

Create your Public OAuth Client

  1. Create a new file called PublicOAuthClient.ts
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<string, string> = {};
 
    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`);
    }
}

Create your MCP Client with OAuth support

  1. Create a new file called index.ts
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<string>((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;
        }
    }
}