Skip to content

feat: allow passing app id + api key directly #23

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 1 commit into from
May 21, 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
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,13 @@

https://github.com/user-attachments/assets/c36a72e0-f790-4b3f-8720-294ab7f5f6eb


This repository contains experimental Model Context Protocol (or MCP) servers for interacting with Algolia APIs. We're sharing it for you to explore and experiment with.
Feel free to use it, fork it, or build on top of it — but just know that it's not officially supported by Algolia and isn't covered under our SLA.
This repository contains experimental Model Context Protocol (or MCP) servers for interacting with Algolia APIs. We're sharing it for you to explore and experiment with.
Feel free to use it, fork it, or build on top of it — but just know that it's not officially supported by Algolia and isn't covered under our SLA.

We might update it, break it, or remove it entirely at any time. If you customize or configure things here, there's a chance that work could be lost. Also, using MCP in production could affect your Algolia usage.

If you have feedback or ideas (even code!), we'd love to hear it. Just know that we might use it to help improve our products. This project is provided "as is" and "as available," with no guarantees or warranties. To be super clear: MCP isn't considered an "API Client" for SLA purposes.


## ✨ Quick Start

1. **Download** the latest release from our [GitHub Releases](https://github.com/algolia/mcp-node/releases)
Expand All @@ -48,31 +46,36 @@ Algolia Node.js MCP enables natural language interactions with your Algolia data
Here are some example prompts to get you started:

### Account Management

```
"What is the email address associated with my Algolia account?"
```

### Applications

```
"List all my Algolia apps."
"List all the indices are in my 'e-commerce' application and format them into a table sorted by entries."
"Show me the configuration for my 'products' index."
```

### Search & Indexing

```
"Search my 'products' index for Nike shoes under $100."
"Add the top 10 programming books to my 'library' index using their ISBNs as objectIDs."
"How many records do I have in my 'customers' index?"
```

### Analytics & Insights

```
"What's the no-results rate for my 'products' index in the DE region? Generate a graph using React and Recharts."
"Show me the top 10 searches with no results in the DE region from last week."
```

### Monitoring & Performance

```
"Are there any ongoing incidents at Algolia?"
"What's the current latency for my 'e-commerce' index?"
Expand Down Expand Up @@ -101,7 +104,7 @@ Here are some example prompts to get you started:

### Windows & Linux

*Coming soon.*
_Coming soon._

## ⚙️ Configuration

Expand Down Expand Up @@ -149,9 +152,9 @@ Usage: algolia-mcp start-server [options]
Starts the Algolia MCP server

Options:
-o, --allow-tools <tools> Comma separated list of tool ids (default:
["listIndices","getSettings","searchSingleIndex","getTopSearches","getTopHits","getNoResultsRate"])
-h, --help display help for command
-t, --allow-tools <tools> Comma separated list of tool ids (default: getUserInfo,getApplications,...,listIndices)
--credentials <applicationId:apiKey> Application ID and associated API key to use. Optional: the MCP will authenticate you if unspecified, giving you access to all your applications.
-h, --help display help for command
```

## 🛠 Development
Expand All @@ -164,6 +167,7 @@ Options:
### Setup Development Environment

1. Clone the repository:

```sh
git clone https://github.com/algolia/mcp-node
cd mcp-node
Expand Down Expand Up @@ -199,6 +203,7 @@ npm run build -- --outfile dist/algolia-mcp
Use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) for testing and debugging:

1. Run the debug script:

```sh
cd mcp-node
npm run debug
Expand All @@ -219,6 +224,7 @@ Use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) fo
### Logs and Diagnostics

Log files are stored in:

- macOS: `~/Library/Logs/algolia-mcp/`
- Windows: `%APPDATA%\algolia-mcp\logs\`
- Linux: `~/.config/algolia-mcp/logs/`
Expand Down
7 changes: 4 additions & 3 deletions src/DashboardApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const CreateApiKeyResponse = z.object({
});
type CreateApiKeyResponse = z.infer<typeof CreateApiKeyResponse>;

const ACL = [
export const REQUIRED_ACLS = [
"search",
"listIndexes",
"analytics",
Expand Down Expand Up @@ -123,7 +123,8 @@ export class DashboardApi {
const apiKeys = this.#options.appState.get("apiKeys");
let apiKey: string | undefined = apiKeys[applicationId];

const shouldCreateApiKey = !apiKey || !(await this.#hasRightAcl(applicationId, apiKey, ACL));
const shouldCreateApiKey =
!apiKey || !(await this.#hasRightAcl(applicationId, apiKey, REQUIRED_ACLS));

if (shouldCreateApiKey) {
apiKey = await this.#createApiKey(applicationId);
Expand All @@ -148,7 +149,7 @@ export class DashboardApi {
{
method: "POST",
body: JSON.stringify({
acl: ACL,
acl: REQUIRED_ACLS,
description: "API Key created by and for the Algolia MCP Server",
}),
},
Expand Down
38 changes: 34 additions & 4 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Command } from "commander";
import { type StartServerOptions } from "./commands/start-server.ts";
import { type ListToolsOptions } from "./commands/list-tools.ts";
import { ZodError } from "zod";

const program = new Command("algolia-mcp");

Expand Down Expand Up @@ -58,13 +58,43 @@ const ALLOW_TOOLS_OPTIONS_TUPLE = [
DEFAULT_ALLOW_TOOLS,
] as const;

function formatErrorForCli(error: unknown): string {
if (error instanceof ZodError) {
return [...error.errors.map((e) => `- ${e.path.join(".") || "<root>"}: ${e.message}`)].join(
"\n",
);
}

if (error instanceof Error) {
return error.message;
}

return "Unknown error";
}

program
.command("start-server", { isDefault: true })
.description("Starts the Algolia MCP server")
.option<string[]>(...ALLOW_TOOLS_OPTIONS_TUPLE)
.action(async (opts: StartServerOptions) => {
const { startServer } = await import("./commands/start-server.ts");
await startServer(opts);
.option(
"--credentials <applicationId:apiKey>",
"Application ID and associated API key to use. Optional: the MCP will authenticate you if unspecified, giving you access to all your applications.",
(val) => {
const [applicationId, apiKey] = val.split(":");
if (!applicationId || !apiKey) {
throw new Error("Invalid credentials format. Use applicationId:apiKey");
}
return { applicationId, apiKey };
},
)
.action(async (opts) => {
try {
const { startServer } = await import("./commands/start-server.ts");
await startServer(opts);
} catch (error) {
console.error(formatErrorForCli(error));
process.exit(1);
}
});

program
Expand Down
154 changes: 154 additions & 0 deletions src/commands/start-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";

import { startServer } from "./start-server.ts";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { setupServer } from "msw/node";
import { http } from "msw";
import { ZodError } from "zod";
import type { AppState } from "../appState.ts";
import { AppStateManager } from "../appState.ts";
import { REQUIRED_ACLS } from "../DashboardApi.ts";

const mswServer = setupServer();

beforeAll(() => mswServer.listen());
afterEach(() => mswServer.resetHandlers());
afterAll(() => mswServer.close());

describe("when specifying credentials flag", () => {
it("should throw if params are missing", async () => {
await expect(
startServer({
// @ts-expect-error -- I'm testing missing params
credentials: { applicationId: "appId" },
}),
).rejects.toThrow(ZodError);
await expect(
startServer({
// @ts-expect-error -- I'm testing missing params
credentials: { apiKey: "apiKey" },
}),
).rejects.toThrow(ZodError);
});

it("should not throw if both params are provided", async () => {
vi.spyOn(AppStateManager, "load").mockRejectedValue(new Error("Should not be called"));
const server = await startServer({ credentials: { applicationId: "appId", apiKey: "apiKey" } });

expect(AppStateManager.load).not.toHaveBeenCalled();

await server.close();
});

it("should allow filtering tools", async () => {
mswServer.use(
http.put("https://appid.algolia.net/1/indexes/indexName/settings", () =>
Response.json({ taskId: 123 }),
),
);
const client = new Client({ name: "test client", version: "1.0.0" });
const server = await startServer({
credentials: {
apiKey: "apiKey",
applicationId: "appId",
},
allowTools: ["setSettings"],
});

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

const { tools } = await client.listTools();

expect(tools).toHaveLength(1);
expect(tools[0].name).toBe("setSettings");

const result = await client.callTool({
name: "setSettings",
arguments: {
indexName: "indexName",
requestBody: {
searchableAttributes: ["title"],
},
},
});

expect(result).toMatchInlineSnapshot(`
{
"content": [
{
"text": "{"taskId":123}",
"type": "text",
},
],
}
`);

await server.close();
});
});

describe("default behavior", () => {
beforeEach(() => {
const mockAppState: AppState = {
accessToken: "accessToken",
refreshToken: "refreshToken",
apiKeys: {
appId: "apiKey",
},
};
vi.spyOn(AppStateManager, "load").mockResolvedValue(
// @ts-expect-error -- It's just a partial mock
{
get: vi.fn(<K extends keyof AppState>(k: K) => mockAppState[k]),
update: vi.fn(),
},
);
});

it("should list dashboard tools", async () => {
const client = new Client({ name: "test client", version: "1.0.0" });
const server = await startServer({});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

expect(AppStateManager.load).toHaveBeenCalled();

const { tools } = await client.listTools();
expect(tools).toHaveLength(176);
expect(tools.some((t) => t.name === "getUserInfo")).toBe(true);
});

it("should fetch the api key automatically", async () => {
mswServer.use(
http.get("https://appid-dsn.algolia.net/1/keys/apiKey", () =>
Response.json({ acl: REQUIRED_ACLS }),
),
http.get("https://appid.algolia.net/1/indexes/indexName/settings", () => Response.json({})),
);
const client = new Client({ name: "test client", version: "1.0.0" });
const server = await startServer({ allowTools: ["getSettings"] });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

const result = await client.callTool({
name: "getSettings",
arguments: {
applicationId: "appId",
indexName: "indexName",
},
});

expect(result).toMatchInlineSnapshot(`
{
"content": [
{
"text": "{}",
"type": "text",
},
],
}
`);
});
});
Loading