Skip to content

Remote MCP Client #246

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions examples/code/home/mcp/remote-code-client/PublicOAuthClient.ts.fake
Original file line number Diff line number Diff line change
@@ -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<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`);
}
}
166 changes: 166 additions & 0 deletions examples/code/home/mcp/remote-code-client/index.ts.fake
Original file line number Diff line number Diff line change
@@ -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<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;
}
}
}
Loading