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

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
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] - {PR_MERGE_DATE}

## [Initial Version] - 2025-02-01
117 changes: 104 additions & 13 deletions extensions/metabase/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 37 additions & 1 deletion extensions/metabase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,41 @@
"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.",
"evals": [
{
"input": "@metabase How many users do I have?",
"expected": []
},
{
"input": "@metabase What databases do I have connected?",
"expected": []
}
Comment on lines +48 to +56
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Evals are empty arrays. Need at least one eval with expected output to validate AI behavior. See https://developers.raycast.com/ai/write-evals-for-your-ai-extension

]
},
"preferences": [
{
"name": "instanceUrl",
Expand All @@ -41,7 +76,8 @@
],
"dependencies": {
"@raycast/api": "^1.90.0",
"@raycast/utils": "^1.17.0"
"@raycast/utils": "^1.17.0",
"node-fetch": "^3.3.2"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.11",
Expand Down
137 changes: 137 additions & 0 deletions extensions/metabase/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { getPreferenceValues } from "@raycast/api";
import fetch from "node-fetch";

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 +51 to +71
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 +91 to +93
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<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
Loading