Arcade MCP (MCP Server SDK) - TypeScript Overview
arcade-mcp, the secure framework for building servers, provides a clean, minimal API to build programmatically. It handles collection, server configuration, and transport setup with a developer-friendly interface.
Installation
bun add arcade-mcptsconfig: Run bun init to generate one, or ensure yours has these essentials:
{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler",
"target": "ESNext",
"module": "Preserve"
}
}See Bun TypeScript docs for the complete recommended config.
Imports
// Main exports
import { MCPApp, tool } from 'arcade-mcp';
import { NotFoundError, RetryableToolError, FatalToolError } from 'arcade-mcp';
// Auth providers
import { Google, GitHub, Slack } from 'arcade-mcp/auth';
// Error adapters
import { SlackErrorAdapter, GoogleErrorAdapter } from 'arcade-mcp/adapters';Quick Start
// server.ts
import { MCPApp } from 'arcade-mcp';
import { z } from 'zod';
const app = new MCPApp({ name: 'my-server', version: '1.0.0' });
app.tool('greet', {
description: 'Greet a person by name',
input: z.object({
name: z.string().describe('The name of the person to greet'),
}),
handler: ({ input }) => `Hello, ${input.name}!`,
});
app.run({ transport: 'http', port: 8000, reload: true });bun run server.tsTest it works:
curl -X POST http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'When Claude (or another AI) calls your greet with { name: "Alex" }, your handler runs and returns the greeting.
Transport auto-detection: stdio for Claude Desktop, HTTP when you specify host/port.
The reload: true option enables hot reload during development.
API Reference
MCPApp
arcade-mcp.MCPApp
A type-safe, developer-friendly interface for building servers. Handles registration, configuration, and transport.
Constructor
new MCPApp(options?: MCPAppOptions)interface MCPAppOptions {
/** Server name shown to AI clients */
name?: string;
/** Server version */
version?: string;
/** Human-readable title */
title?: string;
/** Usage instructions for AI clients */
instructions?: string;
/** Logging level */
logLevel?: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR';
/** Transport type: 'stdio' for Claude Desktop, 'http' for web */
transport?: 'stdio' | 'http';
/** HTTP host (auto-selects HTTP transport if set) */
host?: string;
/** HTTP port (auto-selects HTTP transport if set) */
port?: number;
/** Hot reload on file changes (development only) */
reload?: boolean;
}Defaults:
| Option | Default | Notes |
|---|---|---|
name | 'ArcadeMCP' | |
version | '1.0.0' | |
logLevel | 'INFO' | |
transport | 'stdio' | Auto-switches to 'http' if host/port set |
host | '127.0.0.1' | |
port | 8000 |
app.tool()
Register a that AI clients can call.
app.tool(name: string, options: ToolOptions): voidinterface ToolOptions<
TInput,
TSecrets extends string = string,
TAuth extends AuthProvider | undefined = undefined
> {
/** Tool description for AI clients */
description?: string;
/** Zod schema for input validation */
input: z.ZodType<TInput>;
/** Tool handler function — authorization is non-optional when requiresAuth is set */
handler: (
context: ToolContext<TInput, TSecrets, TAuth extends AuthProvider ? true : false>
) => unknown | Promise<unknown>;
/** OAuth provider for user authentication */
requiresAuth?: TAuth;
/** Secret keys required by this tool (use `as const` for type safety) */
requiresSecrets?: readonly TSecrets[];
/** Metadata keys required from the client */
requiresMetadata?: string[];
/** Error adapters for translating upstream errors (e.g., Slack, Google APIs) */
adapters?: ErrorAdapter[];
}Handler (destructure what you need):
handler: ({ input, authorization, getSecret, getMetadata }) => {
// input — Validated input matching your Zod schema
// authorization — OAuth token/provider (TypeScript narrows to non-optional when requiresAuth is set)
// getSecret — Retrieve secrets (type-safe with `as const`)
// getMetadata — Retrieve metadata from the client
}Return values are auto-wrapped:
| Return type | Becomes |
|---|---|
string | { content: [{ type: 'text', text: '...' }] } |
object | { content: [{ type: 'text', text: JSON.stringify(...) }] } |
{ content: [...] } | Passed through unchanged |
app.run()
Start the server.
app.run(options?: RunOptions): Promise<void>interface RunOptions {
transport?: 'stdio' | 'http';
host?: string;
port?: number;
reload?: boolean;
}app.addToolsFromModule()
Add all from a module at once.
app.tool() vs tool():
app.tool('name', { ... })— Register a directlytool({ ... })— Create a foraddToolsFromModule()or runtimeapp.tools.add()
Use the standalone tool() function to define exportable , then addToolsFromModule() discovers and registers them:
// tools/math.ts
import { tool } from 'arcade-mcp';
import { z } from 'zod';
export const add = tool({
description: 'Add two numbers',
input: z.object({ a: z.number(), b: z.number() }),
handler: ({ input }) => input.a + input.b,
});
export const multiply = tool({
description: 'Multiply two numbers',
input: z.object({ a: z.number(), b: z.number() }),
handler: ({ input }) => input.a * input.b,
});// server.ts
import * as mathTools from './tools/math';
app.addToolsFromModule(mathTools); names are inferred from export names (add, multiply). Override with explicit name if needed:
export const calculator = tool({
name: 'basic-calculator', // explicit name overrides 'calculator'
description: 'Basic arithmetic',
input: z.object({
operation: z.enum(['add', 'subtract', 'multiply', 'divide']),
a: z.number(),
b: z.number(),
}),
handler: ({ input }) => {
const { operation, a, b } = input;
switch (operation) {
case 'add': return a + b;
case 'subtract': return a - b;
case 'multiply': return a * b;
case 'divide': return a / b;
}
},
});Runtime APIs
After the server starts, you can modify , prompts, and resources at runtime:
import { tool } from 'arcade-mcp';
import { z } from 'zod';
// Create and add a tool at runtime
const dynamicTool = tool({
name: 'dynamic-tool', // name required for runtime registration
description: 'Added at runtime',
input: z.object({ value: z.string() }),
handler: ({ input }) => `Got: ${input.value}`,
});
await app.tools.add(dynamicTool);
// Remove a tool
await app.tools.remove('dynamic-tool');
// List all tools
const tools = await app.tools.list();// Add a prompt (reusable message templates for AI clients)
await app.prompts.add(prompt, handler);
// Add a resource (files, data, or content the AI can read)
await app.resources.add(resource);primitives: are functions AI can call. Prompts are reusable message templates (like “summarize this”). Resources are data the AI can read (files, database records, API responses).
See the Server reference for full prompts and resources API.
Examples
Simple Tool
import { MCPApp } from 'arcade-mcp';
import { z } from 'zod';
const app = new MCPApp({ name: 'example-server' });
app.tool('echo', {
description: 'Echo the text back',
input: z.object({
text: z.string().describe('The text to echo'),
}),
handler: ({ input }) => `Echo: ${input.text}`,
});
app.run({ transport: 'http', host: '0.0.0.0', port: 8000 });With OAuth and Secrets
Use requiresAuth when your tool needs to act on behalf of a user (e.g., access their Google ). Use requiresSecrets for your server needs.
import { MCPApp } from 'arcade-mcp';
import { Google } from 'arcade-mcp/auth';
import { z } from 'zod';
const app = new MCPApp({ name: 'my-server' });
app.tool('getProfile', {
description: 'Get user profile from Google',
input: z.object({
userId: z.string(),
}),
requiresAuth: Google({ scopes: ['profile'] }),
requiresSecrets: ['API_KEY'] as const, // as const enables type-safe getSecret()
handler: async ({ input, authorization, getSecret }) => {
const token = authorization.token; // User's OAuth token
const apiKey = getSecret('API_KEY'); // ✅ Type-safe, autocomplete works
// getSecret('OTHER'); // ❌ TypeScript error: not in requiresSecrets
return { userId: input.userId };
},
});
app.run();How auth works: When a requires auth, Arcade coordinates consent via OAuth. The token is passed to your handler automatically.
Secrets are loaded from environment variables at startup. Never log or return them.
With Required Metadata
Request metadata from the client:
app.tool('contextAware', {
description: 'A tool that uses client context',
input: z.object({ query: z.string() }),
requiresMetadata: ['sessionId', 'userAgent'],
handler: ({ input, getMetadata }) => {
const sessionId = getMetadata('sessionId'); // string | undefined
return `Processing ${input.query} for session ${sessionId}`;
},
});Schema Metadata for AI Clients
Use .describe() for simple descriptions. Use .meta() for richer metadata:
app.tool('search', {
description: 'Search the knowledge base',
input: z.object({
query: z.string().describe('Search query'),
limit: z.number()
.int()
.min(1)
.max(100)
.default(10)
.meta({
title: 'Result limit',
examples: [10, 25, 50],
}),
}),
handler: ({ input }) => searchKnowledgeBase(input.query, input.limit),
});Both .describe() and .meta() are preserved in the JSON Schema that AI clients receive.
Async Tool with Error Handling
import { MCPApp, NotFoundError } from 'arcade-mcp';
import { z } from 'zod';
const app = new MCPApp({ name: 'api-server' });
app.tool('getUser', {
description: 'Fetch a user by ID',
input: z.object({
id: z.string().uuid().describe('User ID'),
}),
handler: async ({ input }) => {
const user = await db.users.find(input.id);
if (!user) {
throw new NotFoundError(`User ${input.id} not found`);
}
return user;
},
});Full Example with All Features
import { MCPApp } from 'arcade-mcp';
import { Google } from 'arcade-mcp/auth';
import { z } from 'zod';
const app = new MCPApp({
name: 'full-example',
version: '1.0.0',
instructions: 'Use these tools to manage documents.',
logLevel: 'DEBUG',
});
// Simple tool
app.tool('ping', {
description: 'Health check',
input: z.object({}),
handler: () => 'pong',
});
// Complex tool with auth and secrets
app.tool('createDocument', {
description: 'Create a new document in Google Drive',
input: z.object({
title: z.string().min(1).describe('Document title'),
content: z.string().describe('Document content'),
folder: z.string().optional().describe('Parent folder ID'),
}),
requiresAuth: Google({ scopes: ['drive.file'] }),
requiresSecrets: ['DRIVE_API_KEY'] as const,
handler: async ({ input, authorization, getSecret }) => {
const response = await createDriveDocument({
token: authorization.token,
apiKey: getSecret('DRIVE_API_KEY'),
...input,
});
return { documentId: response.id, url: response.webViewLink };
},
});
// Start server
if (import.meta.main) {
app.run({ transport: 'http', host: '0.0.0.0', port: 8000 });
}bun run server.ts