Skip to content

Add Metabase AI Tools #17673

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Jul 7, 2025
Merged
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
4 changes: 0 additions & 4 deletions extensions/metabase/.eslintrc.json

This file was deleted.

2 changes: 2 additions & 0 deletions extensions/metabase/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Metabase Changelog

## [AI Tools] - 2025-07-07

## [Initial Version] - 2025-02-01
4 changes: 4 additions & 0 deletions extensions/metabase/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const { defineConfig } = require("eslint/config");
const raycastConfig = require("@raycast/eslint-config");

module.exports = defineConfig([...raycastConfig]);
1,320 changes: 561 additions & 759 deletions extensions/metabase/package-lock.json

Large diffs are not rendered by default.

203 changes: 196 additions & 7 deletions extensions/metabase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,195 @@
"mode": "view"
}
],
"tools": [
{
"name": "get-databases",
"title": "Get Databases",
"description": "Search the saved databases in Metabase"
},
{
"name": "get-questions",
"title": "Get Questions",
"description": "Get the saved Metabase questions"
},
{
"name": "run-query",
"title": "Run Query",
"description": "Run a native SQL query against a database"
},
{
"name": "run-question",
"title": "Run Question",
"description": "Get latest saved question results"
}
],
"ai": {
"instructions": "When fetching data from Metabase, always retrieve the saved questions and execute them if they match the requested information. If they do not match, get the databases and run a native SQL query based on their metadata. You can retrieve both the saved questions and the databases in parallel so you can make the best decision.\n\nDon't use variables like 'YOUR_USER_ID' when running queries manually.",
"evals": [
{
"input": "@metabase How many registered users do I have?",
"mocks": {
"get-databases": {
"databases": [
{
"engine": "postgres",
"id": 2,
"name": "PostgreSQL",
"schema": {
"enums": [],
"tables": [
{
"columns": {
"created_at": "timestamp with time zone",
"id": "bigint",
"referred_id": "bigint",
"referrer_id": "bigint",
"updated_at": "timestamp with time zone"
},
"name": "referrals"
},
{
"columns": {
"created_at": "timestamp with time zone",
"id": "bigint",
"internal": "boolean",
"referral_code": "text",
"updated_at": "timestamp with time zone",
"username": "text"
},
"name": "users"
}
]
}
}
]
},
"get-questions": {
"questions": [
{
"id": 72,
"name": "Registered Users",
"description": null,
"databaseId": 2,
"databaseQuery": "SELECT count(*) FROM users WHERE username is not null"
}
]
},
"run-question": {
"result": [
[
382194
]
]
}
},
"expected": [
{
"callsTool": {
"arguments": {},
"name": "get-questions"
}
},
{
"callsTool": {
"arguments": {},
"name": "get-databases"
}
},
{
"callsTool": {
"arguments": {
"questionId": 72,
"requiresConfirmation": true
},
"name": "run-question"
}
}
]
},
{
"input": "@metabase When did the user named \"vimtor\" sign up?",
"mocks": {
"get-databases": {
"databases": [
{
"engine": "postgres",
"id": 2,
"name": "PostgreSQL",
"schema": {
"enums": [],
"tables": [
{
"columns": {
"created_at": "timestamp with time zone",
"id": "bigint",
"referred_id": "bigint",
"referrer_id": "bigint",
"updated_at": "timestamp with time zone"
},
"name": "referrals"
},
{
"columns": {
"created_at": "timestamp with time zone",
"id": "bigint",
"internal": "boolean",
"referral_code": "text",
"updated_at": "timestamp with time zone",
"username": "text"
},
"name": "users"
}
]
}
}
]
},
"get-questions": {
"questions": [
{
"id": 72,
"name": "Registered Users",
"description": null,
"databaseId": 2,
"databaseQuery": "SELECT count(*) FROM users WHERE username is not null"
}
]
},
"run-query": {
"result": [
[
"2024-12-13T21:01:06.329864Z"
]
]
}
},
"expected": [
{
"callsTool": {
"arguments": {},
"name": "get-questions"
}
},
{
"callsTool": {
"arguments": {},
"name": "get-databases"
}
},
{
"callsTool": {
"arguments": {
"databaseId": 2,
"query": "SELECT created_at FROM users WHERE username = 'vimtor' LIMIT 1;"
},
"name": "run-query"
}
}
]
}
]
},
"preferences": [
{
"name": "instanceUrl",
Expand All @@ -40,16 +229,16 @@
}
],
"dependencies": {
"@raycast/api": "^1.90.0",
"@raycast/api": "^1.100.3",
"@raycast/utils": "^1.17.0"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.11",
"@types/node": "20.8.10",
"@types/react": "18.3.3",
"eslint": "^8.57.0",
"prettier": "^3.3.3",
"typescript": "^5.4.5"
"@raycast/eslint-config": "^2.0.4",
"@types/node": "22.13.10",
"@types/react": "19.0.10",
"eslint": "^9.22.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
},
"scripts": {
"build": "ray build",
Expand Down
136 changes: 136 additions & 0 deletions extensions/metabase/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { getPreferenceValues } from "@raycast/api";

const preferences = getPreferenceValues<Preferences.SearchQuestions>();

export interface Database {
id: number;
name: string;
engine: string;
tables: {
name: string;
schema: string;
// and other fields
}[];
}

export interface Card {
id: number;
name: string;
description: string | null;
created_at: string;
updated_at: string;
archived: boolean;
view_count: number;
display: "bar" | "table" | "scalar" | "line" | "pie" | (string & Record<string, never>);
dataset_query: {
database: number;
native: {
query: string;
template_tags: Record<string, { display_name: string; id: string; name: string; type: string }>;
};
type: "native";
};
creator: {
email: string;
first_name: string;
last_login: string;
is_qbnewb: boolean;
is_superuser: boolean;
id: number;
last_name: string;
date_joined: string;
common_name: string;
};
}

export interface Query {
rows: string[][];
}

export async function runQuery(input: { query: string; databaseId: number }) {
const endpoint = new URL(`/api/dataset`, preferences.instanceUrl);

return fetch(endpoint.toString(), {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
"x-api-key": preferences.apiToken,
},
body: JSON.stringify({
database: input.databaseId,
type: "native",
native: {
query: input.query,
},
}),
})
.then((res) => res.json() as Promise<{ data: Query }>)
.then((res) => res.data);
}
Comment on lines +50 to +70
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: No error handling for failed requests. Should check res.ok before parsing JSON and handle network errors in the catch block. Consider using showFailureToast from @raycast/utils for error handling.

Suggested change
export async function runQuery(input: { query: string; databaseId: number }) {
const endpoint = new URL(`/api/dataset`, preferences.instanceUrl);
return fetch(endpoint.toString(), {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
"x-api-key": preferences.apiToken,
},
body: JSON.stringify({
database: input.databaseId,
type: "native",
native: {
query: input.query,
},
}),
})
.then((res) => res.json() as Promise<{ data: Query }>)
.then((res) => res.data);
}
export async function runQuery(input: { query: string; databaseId: number }) {
const endpoint = new URL(`/api/dataset`, preferences.instanceUrl);
try {
const response = await fetch(endpoint.toString(), {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
"x-api-key": preferences.apiToken,
},
body: JSON.stringify({
database: input.databaseId,
type: "native",
native: {
query: input.query,
},
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json() as { data: Query };
return json.data;
} catch (error) {
showFailureToast(error instanceof Error ? error.message : "Failed to run query");
throw error;
}
}


export async function getQuestion(input: { questionId: number }) {
const endpoint = new URL(`/api/card/${input.questionId}`, preferences.instanceUrl);

return fetch(endpoint.toString(), {
method: "GET",
headers: {
accept: "application/json",
"x-api-key": preferences.apiToken,
},
}).then((res) => res.json() as Promise<Card>);
}

export async function runQuestion(input: { questionId: number }) {
const endpoint = new URL(`/api/card/${input.questionId}/query`, preferences.instanceUrl);

return fetch(endpoint.toString(), {
method: "POST",
headers: {
accept: "application/json",
contentType: "application/json",
"x-api-key": preferences.apiToken,
Comment on lines +90 to +92
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syntax: Header name should be 'content-type' (hyphenated) instead of 'contentType' to match other requests and HTTP standards

Suggested change
accept: "application/json",
contentType: "application/json",
"x-api-key": preferences.apiToken,
accept: "application/json",
"content-type": "application/json",
"x-api-key": preferences.apiToken,

},
body: JSON.stringify({}),
}).then((res) => res.json() as Promise<{ data: Query }>);
}

export async function getDatabases() {
const endpoint = new URL("/api/database", preferences.instanceUrl);

endpoint.searchParams.set("include", "tables");

return fetch(endpoint.toString(), {
method: "GET",
headers: {
accept: "application/json",
"x-api-key": preferences.apiToken,
},
})
.then((res) => res.json() as Promise<{ data: Database[] }>)
.then((res) => res.data);
}

export async function getDatabase(databaseId: number) {
const endpoint = new URL(`/api/database/${databaseId}`, preferences.instanceUrl);

return fetch(endpoint.toString(), {
method: "GET",
headers: {
accept: "application/json",
"x-api-key": preferences.apiToken,
},
}).then((res) => res.json() as Promise<Database>);
}

export async function getQuestions() {
const endpoint = new URL("/api/card", preferences.instanceUrl);

return fetch(endpoint.toString(), {
method: "GET",
headers: {
accept: "application/json",
"x-api-key": preferences.apiToken,
},
}).then((res) => res.json() as Promise<Card[]>);
}
Loading