Install
$ agentstack add mcp-arcadeai-arcade-mcp-ts ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
@arcadeai/arcade-mcp
TypeScript MCP framework with secret injection, OAuth auth providers, multi-user support, worker routes, and middleware. Wraps the official @modelcontextprotocol/sdk — never forks or patches it.
Quick Start
bun add @arcadeai/arcade-mcp
import { MCPApp } from "@arcadeai/arcade-mcp";
import { z } from "zod";
const app = new MCPApp({
name: "MyServer",
version: "1.0.0",
instructions: "A helpful tool server",
});
app.tool(
"greet",
{
description: "Greet someone by name",
parameters: z.object({
name: z.string().describe("Name to greet"),
}),
},
async (args) => `Hello, ${args.name}!`,
);
app.run(); // stdio by default
Run it:
bun run server.ts
Or over HTTP:
app.run({ transport: "http", port: 8000 });
CLI Auto-Discovery
Run an MCP server without writing a server file. The CLI auto-discovers tool modules in the current directory:
npx @arcadeai/arcade-mcp # auto-discover tools, run stdio
npx @arcadeai/arcade-mcp --http # auto-discover tools, run HTTP
Tool modules are discovered from:
*.tools.ts/*.tools.jsfiles (e.g.,math.tools.ts)- Any file inside a
tools/directory (e.g.,tools/greet.ts)
Each file should export tool definitions:
// tools/greet.ts
import { z } from "zod";
export const greetTools = {
greet: {
options: {
description: "Greet someone",
parameters: z.object({ name: z.string() }),
},
handler: async (args) => `Hello, ${args.name}!`,
},
};
CLI options:
| Flag | Default | Description | |---|---|---| | --http | — | Use HTTP transport (default: stdio) | | --host | 127.0.0.1 | HTTP host | | --port | 8000 | HTTP port | | --name | directory name | App name | | --dir | cwd | Directory to scan | | --dev | — | Auto-reload on file changes (HTTP only) |
> Node.js + TypeScript: Use npx tsx arcade-mcp or Bun to import .ts tool files directly.
Dev Mode (Auto-Reload)
Watch source files and automatically restart the server on changes:
npx @arcadeai/arcade-mcp --http --dev
Or programmatically:
app.run({ transport: "http", dev: true });
When a .ts, .js, .mts, or .mjs file changes, the server stops, re-imports tool modules with fresh copies, and restarts. Files in node_modules/, dist/, and hidden directories are ignored.
> Note: Dev mode only works with HTTP transport. Stdio sessions cannot be restarted.
You can also enable dev mode via the ARCADE_SERVER_RELOAD=1 environment variable.
Features
- Builder API —
app.tool(name, options, handler)with method chaining - Secret injection — env vars auto-captured and injected into tool context
- OAuth auth providers — 21 providers: GitHub, Google, Slack, Microsoft, Linear, Notion, etc.
- Context object — namespaced facades for logging, progress, sampling, resources, tools, UI
- Middleware — composable onion-model middleware with method-specific hooks
- Multi-user HTTP auth — JWT Bearer token validation via JWKS
- Worker routes —
/worker/tools,/worker/tools/invoke,/worker/health - Error hierarchy — structured errors with retry support, upstream error mapping
- Prompts —
app.prompt(name, options, handler)with argument validation and runtime management - Resources —
app.resource(uri, options, handler)with MIME types and runtime management - Dev mode — auto-reload on file changes with
--devflag (HTTP only) - Resumable streams — optional event store for HTTP stream resumability via
Last-Event-ID - Evals — evaluate LLM tool-calling accuracy with critics, rubrics, and Hungarian-optimal matching
- Dual transport — stdio and HTTP (Elysia + StreamableHTTP)
- Runtime compatible — Bun and Node.js (no
Bun.*APIs in library code)
Tool Options
Basic Tool
app.tool(
"echo",
{
description: "Echo a message",
parameters: z.object({
message: z.string(),
}),
},
async (args) => args.message,
);
Tool with OAuth
import { auth } from "@arcadeai/arcade-mcp";
app.tool(
"star_repo",
{
description: "Star a GitHub repository",
parameters: z.object({
owner: z.string(),
repo: z.string(),
}),
auth: auth.GitHub({ scopes: ["repo"] }),
},
async (args, context) => {
const token = context.getAuthToken();
// ... use token to call GitHub API
return { starred: true };
},
);
Tool with Secrets
app.tool(
"get_repo",
{
description: "Get repo info",
parameters: z.object({ repo: z.string() }),
secrets: ["GITHUB_TOKEN"],
},
async (args, context) => {
const token = context.getSecret("GITHUB_TOKEN");
// ... use token
},
);
Any env var not prefixed with MCP_ or _ is available as a tool secret.
Tool with Behavior Hints
Annotate tools with behavioral hints that map to MCP ToolAnnotations:
app.tool(
"delete_file",
{
description: "Delete a file from the workspace",
parameters: z.object({ path: z.string() }),
behavior: {
readOnly: false,
destructive: true,
idempotent: true,
openWorld: false,
},
},
async (args) => {
// ...
},
);
These hints are exposed as readOnlyHint, destructiveHint, idempotentHint, and openWorldHint in the MCP tool listing.
Deprecated Tools
Mark tools as deprecated — the message is prepended to the description:
app.tool(
"old_search",
{
description: "Search for items",
parameters: z.object({ query: z.string() }),
deprecationMessage: "Use search_v2 instead",
},
async (args) => {
// ...
},
);
// Description seen by clients: "[DEPRECATED: Use search_v2 instead] Search for items"
Tool Title
Provide a human-readable display name:
app.tool(
"gh_star",
{
description: "Star a GitHub repository",
parameters: z.object({ repo: z.string() }),
title: "Star Repository",
},
async (args) => {
// ...
},
);
Toolkit Versioning
The app's name, version, and title are automatically attached to every tool as toolkit metadata. You can also override toolkit info per-tool:
app.tool(
"myTool",
{
description: "A tool with custom toolkit info",
parameters: z.object({}),
toolkit: { name: "my-toolkit", version: "1.2.0" },
},
async () => {},
);
Versions are normalized to semver — "1" becomes "1.0.0", "v1.2" becomes "1.2.0".
Prompts
Register prompts with app.prompt(name, options, handler?):
app.prompt(
"greeting",
{
description: "Generate a greeting",
arguments: [{ name: "name", description: "Name to greet", required: true }],
},
(args) => ({
messages: [
{
role: "user",
content: { type: "text", text: `Please greet ${args.name} warmly.` },
},
],
}),
);
Options: description? and arguments? (array of { name, description?, required? }). If no handler is provided, a default handler returns the description as a user message.
Runtime management (after app.run()):
app.prompts.add("new-prompt", { description: "Added at runtime" }, handler);
app.prompts.remove("new-prompt");
app.prompts.list(); // returns registered prompt names
Resources
Register resources with app.resource(uri, options, handler?):
app.resource(
"config://app",
{ description: "Application configuration", mimeType: "application/json" },
(uri) => ({
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({ name: "EchoServer", version: "1.0.0" }),
},
],
}),
);
Options: description? and mimeType?. If no handler is provided, a default handler returns empty text content.
Runtime management (after app.run()):
app.resources.add("data://users", { mimeType: "application/json" }, handler);
app.resources.remove("data://users");
app.resources.list(); // returns registered resource URIs
Auth Providers
Factory functions for 21 OAuth2 providers:
import { auth } from "@arcadeai/arcade-mcp";
auth.GitHub({ scopes: ["repo"] })
auth.Google({ scopes: ["https://www.googleapis.com/auth/calendar"] })
auth.Slack({ scopes: ["chat:write"], id: "my-slack" })
auth.Microsoft()
auth.Linear()
auth.Notion()
// ... Asana, Atlassian, Attio, ClickUp, Discord, Dropbox,
// Figma, Hubspot, LinkedIn, PagerDuty, Reddit, Spotify,
// Twitch, X, Zoom
Arcade Cloud Auth (Local Development)
Tools with auth requirements automatically resolve OAuth tokens through Arcade Cloud. There are two ways to set up credentials:
Option 1: Arcade CLI (recommended)
Install the Arcade CLI and sign in. This stores credentials in ~/.arcade/credentials.yaml which the framework reads automatically:
pip install arcade-ai
arcade login
That's it — no environment variables needed. Run your server and tools will authenticate through your Arcade account:
bun run examples/github-tools/server.ts
Option 2: Environment variables
Set ARCADE_API_KEY and ARCADE_USER_ID directly:
export ARCADE_API_KEY="your-arcade-api-key"
export ARCADE_USER_ID="your-user-id"
Environment variables take priority over the credentials file.
How it works
When a tool with auth is called, the framework calls Arcade Cloud's authorization API:
- First call — returns an authorization URL. Visit the URL in your browser to complete the OAuth flow.
- Retry the tool — the token is now available and injected into
context.getAuthToken().
This is automatic — no code changes needed. The same tools work both locally (via Arcade Cloud auth) and deployed (via worker routes where Arcade Cloud injects tokens directly).
Set ARCADE_AUTH_DISABLED=true to skip auth resolution (useful for testing with mock tokens).
Context
Tool handlers receive (args, context). The context provides namespaced facades:
app.tool("example", opts, async (args, context) => {
// Secrets & auth
context.getSecret("API_KEY");
context.getAuthToken();
context.getAuthTokenOrEmpty();
// Logging
context.log.info("Processing request");
context.log.debug("Details", { extra: "data" });
context.log.warning("Watch out");
context.log.error("Something failed");
// Progress
await context.progress.report(50, 100, "Halfway done");
// Notifications (deduplicated, flushed at end of request)
await context.notifications.tools.listChanged();
await context.notifications.resources.listChanged();
await context.notifications.prompts.listChanged();
// Metadata
context.signal; // AbortSignal
context.sessionId; // string | undefined
context.requestId; // string
context.userId; // string | undefined
});
Middleware
Composable middleware with an onion model. Override any hook:
import { Middleware, composeMiddleware } from "@arcadeai/arcade-mcp";
class RateLimitMiddleware extends Middleware {
async onCallTool(context, next) {
// before
const result = await next(context);
// after
return result;
}
}
const app = new MCPApp({
name: "MyServer",
version: "1.0.0",
middleware: composeMiddleware(
new RateLimitMiddleware(),
),
});
Available hooks: onMessage, onRequest, onCallTool, onListTools, onReadResource, onListResources, onListResourceTemplates, onGetPrompt, onListPrompts.
Built-in middleware (enabled by default):
- ErrorHandlingMiddleware — catches errors, returns structured MCP error responses
- LoggingMiddleware — logs request/response timing (auto-detects TTY for pretty output; override with
MCP_LOG_FORMAT=json|pretty)
Multi-User HTTP Auth
Validate JWT Bearer tokens against JWKS endpoints:
import { MCPApp, JWTResourceServerValidator } from "@arcadeai/arcade-mcp";
const app = new MCPApp({
name: "MyServer",
version: "1.0.0",
auth: new JWTResourceServerValidator({
canonicalUrl: "https://mcp.example.com/mcp",
authorizationServers: [{
authorizationServerUrl: "https://auth.example.com",
issuer: "https://auth.example.com",
jwksUri: "https://auth.example.com/.well-known/jwks.json",
algorithm: "RS256",
expectedAudiences: ["my-client-id"],
}],
}),
});
app.run({ transport: "http", port: 8000 });
Supports RFC 9728 OAuth Protected Resource Metadata discovery. When canonicalUrl has a non-root path (e.g. https://example.com/mcp), both /.well-known/oauth-protected-resource and /.well-known/oauth-protected-resource/mcp are registered for backward compatibility. Responses include CORS headers.
Resumable Streams
Enable HTTP stream resumability so disconnected clients can resume from where they left off using the Last-Event-ID header:
import { MCPApp, InMemoryEventStore } from "@arcadeai/arcade-mcp";
const app = new MCPApp({ name: "MyServer", version: "1.0.0" });
app.run({
transport: "http",
eventStore: new InMemoryEventStore(),
});
The InMemoryEventStore is suitable for single-process deployments. For distributed systems, implement the EventStore interface with a persistent backend:
import type { EventStore, EventId, StreamId } from "@arcadeai/arcade-mcp";
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
class RedisEventStore implements EventStore {
async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise {
// Store in Redis...
}
async replayEventsAfter(
lastEventId: EventId,
{ send }: { send: (eventId: EventId, message: JSONRPCMessage) => Promise },
): Promise {
// Replay from Redis...
}
}
Session Management
The HTTP transport uses an HTTPSessionManager that supports stateful (default) and stateless modes, TTL-based session eviction, and max session caps:
app.run({
transport: "http",
stateless: false, // true = fresh transport per request, no session reuse
sessionTtlMs: 300_000, // evict idle sessions after 5 minutes
maxSessions: 100, // reject new sessions with 503 when at capacity
});
In stateful mode (default), sessions are reused via the mcp-session-id header. Invalid session IDs receive a 400 response.
In stateless mode, every request gets a fresh transport and server — no sessions are tracked.
You can also use HTTPSessionManager directly for more control:
import { HTTPSessionManager } from "@arcadeai/arcade-mcp";
const manager = new HTTPSessionManager({
server: arcadeMcpServer,
sessionTtlMs: 60_000,
maxSessions: 50,
});
// In your HTTP handler:
const response = await manager.handleRequest(request, { authInfo });
// Graceful shutdown:
await manager.close();
Each stateful HTTP session is backed by a ServerSession that adds:
- Initialization state tracking —
NOT_INITIALIZED → INITIALIZING → INITIALIZED - Server-initiated requests —
createMessage(),elicitInput(),listRoots()with timeout and error handling - Session-scoped data — key/value storage per session via
getData()/setData() - Notification broadcasting —
NotificationManagersends tool/resource/prompt list-changed notifications to all or selected sessions
The Context facades context.sampling.createMessage() and context.ui.elicit() automatically delegate to the ServerSession when available.
Worker Routes
When ARCADE_WORKER_SECRET is set, expose tool execution endpoints for Arcade Cloud integration:
import { createWorkerRoutes } from "@arcadeai/arcade-mcp";
const workerApp = createWorkerRoutes({
catalog: app.catalog,
secret:
…
## Source & license
This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [ArcadeAI](https://github.com/ArcadeAI)
- **Source:** [ArcadeAI/arcade-mcp-ts](https://github.com/ArcadeAI/arcade-mcp-ts)
- **License:** MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.