Skip to content
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
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,66 @@ node dist/server.js --write # Write tools only
node dist/server.js --read # Read-only tools only
```

## 🌐 HTTP Stream Transport (Shared Deployment)

The server supports an **HTTP Stream transport** mode that lets a single deployed instance serve multiple users — each request provides its own Metabase credentials via headers. This is the recommended approach for cloud/SaaS deployments and is required by modern MCP clients like **Claude Code CLI**.

### Why HTTP Stream?

| | stdio (classic) | HTTP Stream (new) |
|---|---|---|
| Deployment | One process per user | One shared process |
| Credentials | Env vars at startup | Per-request headers |
| Claude Code CLI | ❌ Not supported | ✅ Supported |
| Cursor, Windsurf | ✅ Supported | ✅ Supported |

### Starting in HTTP Stream mode

```bash
MCP_TRANSPORT=http node dist/server.js
# or with custom port:
MCP_TRANSPORT=http PORT=8011 node dist/server.js --all
```

### Connecting from Claude Code CLI

```bash
claude mcp add --transport http metabase "https://your-deployment.example.com/mcp" \
--header "x-metabase-url: https://your-metabase-instance.com" \
--header "x-metabase-api-key: your_api_key"
```

Or add it globally (available in all projects):

```bash
claude mcp add --transport http --scope user metabase "https://your-deployment.example.com/mcp" \
--header "x-metabase-url: https://your-metabase-instance.com" \
--header "x-metabase-api-key: your_api_key"
```

### Request Headers

| Header | Required | Description |
|--------|----------|-------------|
| `x-metabase-url` | Yes* | Metabase instance URL. Can be omitted if `METABASE_URL` env var is set on the server. |
| `x-metabase-api-key` | Yes** | Metabase API key |
| `x-metabase-username` | Yes** | Metabase username (alternative to API key) |
| `x-metabase-password` | Yes** | Metabase password (required if username is provided) |

\* Falls back to `METABASE_URL` env var if not in header.
\*\* Either `x-metabase-api-key` OR `x-metabase-username` + `x-metabase-password` is required.

### Username/Password via headers

```bash
claude mcp add --transport http metabase "https://your-deployment.example.com/mcp" \
--header "x-metabase-url: https://your-metabase-instance.com" \
--header "x-metabase-username: admin@example.com" \
--header "x-metabase-password: secret"
```

---

## 🔌 Integration Examples

### Claude Desktop
Expand Down Expand Up @@ -381,6 +441,13 @@ npm install
npm run build
```

### Tests
```bash
npm test
# watch mode:
npm run test:watch
```

### Development Mode
```bash
npm run watch
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@
"clean": "rm -rf build dist",
"docker:build": "docker build -t metabase-mcp-server .",
"docker:run": "docker run -it --rm metabase-mcp-server",
"docker:compose": "docker-compose up"
"docker:compose": "docker-compose up",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^0.6.1",
Expand All @@ -56,7 +58,8 @@
},
"devDependencies": {
"@types/node": "^20.17.22",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=20.19.0",
Expand Down
46 changes: 46 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { MetabaseClient } from "./client/metabase-client.js";

/**
* Creates a per-request authenticate handler for HTTP Stream transport.
* Extracts Metabase credentials from request headers and returns a
* session-scoped MetabaseClient.
*/
export function createAuthenticateHandler() {
return (request: any): { metabaseClient: MetabaseClient } => {
const url = (request.headers['x-metabase-url'] as string) || process.env.METABASE_URL;
const apiKey = request.headers['x-metabase-api-key'] as string;
const username = request.headers['x-metabase-username'] as string;
const password = request.headers['x-metabase-password'] as string;

if (!url) {
throw new Response(null, {
status: 401,
statusText: 'Missing Metabase URL: provide x-metabase-url header or METABASE_URL env var',
});
}
if (!apiKey && (!username || !password)) {
throw new Response(null, {
status: 401,
statusText: 'Missing credentials: provide x-metabase-api-key or x-metabase-username + x-metabase-password headers',
});
}

const metabaseClient = new MetabaseClient({ url, apiKey, username, password });
return { metabaseClient };
};
}

/**
* Creates a client resolver that returns the appropriate MetabaseClient
* for the current request context.
* - HTTP mode: returns the per-session client from ctx.session.metabaseClient
* - stdio mode: returns the shared defaultClient
*/
export function createClientResolver(defaultClient: MetabaseClient | null) {
return (ctx?: any): MetabaseClient => {
const sessionClient = ctx?.session?.metabaseClient;
if (sessionClient) return sessionClient;
if (defaultClient) return defaultClient;
throw new Error('No MetabaseClient available — provide credentials via headers');
};
}
66 changes: 43 additions & 23 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,54 +9,63 @@ import { addCardTools } from "./tools/card-tools.js";
import { addTableTools } from "./tools/table-tools.js";
import { addAdditionalTools } from "./tools/additional-tools.js";
import { parseToolFilterOptions } from "./utils/tool-filters.js";
import { createAuthenticateHandler, createClientResolver } from "./auth.js";

// Parse command line arguments for tool filtering
const filterOptions = parseToolFilterOptions();

// Load and validate configuration
const config = loadConfig();
validateConfig(config);
const isHttpMode = process.env.MCP_TRANSPORT === 'http';

// Initialize Metabase client
const metabaseClient = new MetabaseClient(config);
// In stdio mode, create a single shared client from env vars at startup.
// In httpStream mode, each client provides credentials via request headers.
let defaultClient: MetabaseClient | null = null;
if (!isHttpMode) {
const config = loadConfig();
validateConfig(config);
defaultClient = new MetabaseClient(config);
}

// Create FastMCP server
const server = new FastMCP({
const getClient = createClientResolver(defaultClient);

// Build FastMCP server options
const serverOptions: any = {
name: "metabase-server",
version: "2.0.1",
});
};

// Override addTool to apply filtering
if (isHttpMode) {
serverOptions.authenticate = createAuthenticateHandler();
}

// Create FastMCP server
const server = new FastMCP(serverOptions);

// Override addTool to apply tool filtering (unchanged behavior)
const originalAddTool = server.addTool.bind(server);
server.addTool = function(toolConfig: any) {
const { metadata = {}, ...restConfig } = toolConfig;
const { isWrite, isEssential, isRead } = metadata;

// Apply filtering based on selected mode
switch (filterOptions.mode) {
case 'essential':
// Only load essential tools
if (!isEssential) return;
break;
case 'write':
// Load read and write tools
if (!isRead && !isWrite) return;
break;
case 'all':
// Load all tools - no filtering
break;
}

// Register the tool
originalAddTool(restConfig);
};

// Adding all tools to the server
addDashboardTools(server, metabaseClient);
addDatabaseTools(server, metabaseClient);
addCardTools(server, metabaseClient);
addTableTools(server, metabaseClient);
addAdditionalTools(server, metabaseClient);
// Adding all tools — each execute calls getClient(context) internally
addDashboardTools(server, getClient);
addDatabaseTools(server, getClient);
addCardTools(server, getClient);
addTableTools(server, getClient);
addAdditionalTools(server, getClient);

// Log filtering status
console.error(`INFO: Tool filtering mode: ${filterOptions.mode} ${filterOptions.mode === 'essential' ? '(default)' : ''}`);
Expand All @@ -74,6 +83,17 @@ switch (filterOptions.mode) {
}

// Start the server
server.start({
transportType: "stdio",
});
if (isHttpMode) {
console.error(`INFO: Starting HTTP Stream transport on port ${process.env.PORT || '8011'}`);
server.start({
transportType: "httpStream",
httpStream: {
port: parseInt(process.env.PORT || '8011'),
endpoint: "/mcp",
},
});
} else {
server.start({
transportType: "stdio",
});
}
29 changes: 19 additions & 10 deletions src/tools/additional-tools.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod";
import { MetabaseClient } from "../client/metabase-client.js";

export function addAdditionalTools(server: any, metabaseClient: MetabaseClient) {
export function addAdditionalTools(server: any, getClient: (ctx?: any) => MetabaseClient) {

/**
* Get all items within a collection
Expand All @@ -20,7 +20,8 @@ export function addAdditionalTools(server: any, metabaseClient: MetabaseClient)
parameters: z.object({
collection_id: z.number().describe("Collection ID"),
}).strict(),
execute: async (args: { collection_id: number }) => {
execute: async (args: { collection_id: number }, context: any) => {
const metabaseClient = getClient(context);
try {
const result = await metabaseClient.apiCall(
"GET",
Expand Down Expand Up @@ -64,7 +65,8 @@ export function addAdditionalTools(server: any, metabaseClient: MetabaseClient)
item_type: "card" | "dashboard";
item_id: number;
collection_id: number | null;
}) => {
}, context: any) => {
const metabaseClient = getClient(context);
try {
const result = await metabaseClient.apiCall(
"PUT",
Expand Down Expand Up @@ -106,7 +108,8 @@ export function addAdditionalTools(server: any, metabaseClient: MetabaseClient)
table_db_id: z.number().optional().describe("Filter by database ID"),
limit: z.number().optional().describe("Maximum number of results"),
}).strict(),
execute: async (args: any) => {
execute: async (args: any, context: any) => {
const metabaseClient = getClient(context);
try {
const { q, ...other } = args;
const params = new URLSearchParams({ q });
Expand Down Expand Up @@ -144,7 +147,8 @@ export function addAdditionalTools(server: any, metabaseClient: MetabaseClient)
parameters: z.object({
archived: z.boolean().optional().default(false).describe("Include archived collections"),
}).strict(),
execute: async (args: { archived?: boolean } = {}) => {
execute: async (args: { archived?: boolean } = {}, context: any) => {
const metabaseClient = getClient(context);
try {
const collections = await metabaseClient.getCollections(args.archived || false);
return JSON.stringify(collections, null, 2);
Expand Down Expand Up @@ -177,7 +181,8 @@ export function addAdditionalTools(server: any, metabaseClient: MetabaseClient)
parent_id: z.number().optional().describe("Parent collection ID for nested organization"),
color: z.string().optional().describe("Color for the collection (hex code)"),
}).strict(),
execute: async (args: { name: string; description?: string; parent_id?: number; color?: string }) => {
execute: async (args: { name: string; description?: string; parent_id?: number; color?: string }, context: any) => {
const metabaseClient = getClient(context);
try {
const collection = await metabaseClient.createCollection(args);
return JSON.stringify(collection, null, 2);
Expand Down Expand Up @@ -212,7 +217,8 @@ export function addAdditionalTools(server: any, metabaseClient: MetabaseClient)
parent_id: z.number().optional().describe("New parent collection ID"),
color: z.string().optional().describe("New color for the collection"),
}).strict(),
execute: async (args: { collection_id: number; name?: string; description?: string; parent_id?: number; color?: string }) => {
execute: async (args: { collection_id: number; name?: string; description?: string; parent_id?: number; color?: string }, context: any) => {
const metabaseClient = getClient(context);
try {
const { collection_id, ...updates } = args;
const collection = await metabaseClient.updateCollection(collection_id, updates);
Expand Down Expand Up @@ -240,7 +246,8 @@ export function addAdditionalTools(server: any, metabaseClient: MetabaseClient)
parameters: z.object({
collection_id: z.number().describe("The ID of the collection to delete"),
}).strict(),
execute: async (args: { collection_id: number }) => {
execute: async (args: { collection_id: number }, context: any) => {
const metabaseClient = getClient(context);
try {
await metabaseClient.deleteCollection(args.collection_id);
return JSON.stringify({
Expand Down Expand Up @@ -271,7 +278,8 @@ export function addAdditionalTools(server: any, metabaseClient: MetabaseClient)
parameters: z.object({
include_deactivated: z.boolean().optional().default(false).describe("Include deactivated users"),
}).strict(),
execute: async (args: { include_deactivated?: boolean } = {}) => {
execute: async (args: { include_deactivated?: boolean } = {}, context: any) => {
const metabaseClient = getClient(context);
try {
const users = await metabaseClient.getUsers(args.include_deactivated || false);
return JSON.stringify(users, null, 2);
Expand Down Expand Up @@ -299,7 +307,8 @@ export function addAdditionalTools(server: any, metabaseClient: MetabaseClient)
query: z.string().describe("The SQL query to execute in the playground"),
display: z.string().optional().default("table").describe("Display type (table, bar, line, etc.)"),
}).strict(),
execute: async (args: { query: string; display?: string }) => {
execute: async (args: { query: string; display?: string }, context: any) => {
const metabaseClient = getClient(context);
try {
const payload = {
dataset_query: {
Expand Down
Loading