Skip to content

Commit 18f2c84

Browse files
authored
feat: allow passing app id + api key directly (#23)
## 🤔 What - **feat**: allow passing app id + api key directly ## 🤷 Why It can be practical (for hosting the mcp server) to scope the MCP tools to a specific application instead of relying on the oauth behaviour. This also allows the node implementation to function in a way that's similar to the [Go implementation](https://github.com/algolia/mcp). ## 🔍 How Added an optional `--credentials=applicationId:apiKey` cli flags. When present, the MCP server will not use the Dashboard's API tools and endpoint anymore. Instead, the passed credentials will be used directly to query the different public APIs ## 🧪 Testing Added some ✅
1 parent 21b9159 commit 18f2c84

File tree

8 files changed

+408
-187
lines changed

8 files changed

+408
-187
lines changed

README.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,13 @@
1414

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

17-
18-
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.
19-
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.
17+
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.
18+
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.
2019

2120
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.
2221

2322
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.
2423

25-
2624
## ✨ Quick Start
2725

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

5048
### Account Management
49+
5150
```
5251
"What is the email address associated with my Algolia account?"
5352
```
5453

5554
### Applications
55+
5656
```
5757
"List all my Algolia apps."
5858
"List all the indices are in my 'e-commerce' application and format them into a table sorted by entries."
5959
"Show me the configuration for my 'products' index."
6060
```
6161

6262
### Search & Indexing
63+
6364
```
6465
"Search my 'products' index for Nike shoes under $100."
6566
"Add the top 10 programming books to my 'library' index using their ISBNs as objectIDs."
6667
"How many records do I have in my 'customers' index?"
6768
```
6869

6970
### Analytics & Insights
71+
7072
```
7173
"What's the no-results rate for my 'products' index in the DE region? Generate a graph using React and Recharts."
7274
"Show me the top 10 searches with no results in the DE region from last week."
7375
```
7476

7577
### Monitoring & Performance
78+
7679
```
7780
"Are there any ongoing incidents at Algolia?"
7881
"What's the current latency for my 'e-commerce' index?"
@@ -101,7 +104,7 @@ Here are some example prompts to get you started:
101104

102105
### Windows & Linux
103106

104-
*Coming soon.*
107+
_Coming soon._
105108

106109
## ⚙️ Configuration
107110

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

151154
Options:
152-
-o, --allow-tools <tools> Comma separated list of tool ids (default:
153-
["listIndices","getSettings","searchSingleIndex","getTopSearches","getTopHits","getNoResultsRate"])
154-
-h, --help display help for command
155+
-t, --allow-tools <tools> Comma separated list of tool ids (default: getUserInfo,getApplications,...,listIndices)
156+
--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.
157+
-h, --help display help for command
155158
```
156159
157160
## 🛠 Development
@@ -164,6 +167,7 @@ Options:
164167
### Setup Development Environment
165168
166169
1. Clone the repository:
170+
167171
```sh
168172
git clone https://github.com/algolia/mcp-node
169173
cd mcp-node
@@ -199,6 +203,7 @@ npm run build -- --outfile dist/algolia-mcp
199203
Use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) for testing and debugging:
200204
201205
1. Run the debug script:
206+
202207
```sh
203208
cd mcp-node
204209
npm run debug
@@ -219,6 +224,7 @@ Use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) fo
219224
### Logs and Diagnostics
220225
221226
Log files are stored in:
227+
222228
- macOS: `~/Library/Logs/algolia-mcp/`
223229
- Windows: `%APPDATA%\algolia-mcp\logs\`
224230
- Linux: `~/.config/algolia-mcp/logs/`

src/DashboardApi.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ const CreateApiKeyResponse = z.object({
8383
});
8484
type CreateApiKeyResponse = z.infer<typeof CreateApiKeyResponse>;
8585

86-
const ACL = [
86+
export const REQUIRED_ACLS = [
8787
"search",
8888
"listIndexes",
8989
"analytics",
@@ -123,7 +123,8 @@ export class DashboardApi {
123123
const apiKeys = this.#options.appState.get("apiKeys");
124124
let apiKey: string | undefined = apiKeys[applicationId];
125125

126-
const shouldCreateApiKey = !apiKey || !(await this.#hasRightAcl(applicationId, apiKey, ACL));
126+
const shouldCreateApiKey =
127+
!apiKey || !(await this.#hasRightAcl(applicationId, apiKey, REQUIRED_ACLS));
127128

128129
if (shouldCreateApiKey) {
129130
apiKey = await this.#createApiKey(applicationId);
@@ -148,7 +149,7 @@ export class DashboardApi {
148149
{
149150
method: "POST",
150151
body: JSON.stringify({
151-
acl: ACL,
152+
acl: REQUIRED_ACLS,
152153
description: "API Key created by and for the Algolia MCP Server",
153154
}),
154155
},

src/app.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Command } from "commander";
2-
import { type StartServerOptions } from "./commands/start-server.ts";
32
import { type ListToolsOptions } from "./commands/list-tools.ts";
3+
import { ZodError } from "zod";
44

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

@@ -58,13 +58,43 @@ const ALLOW_TOOLS_OPTIONS_TUPLE = [
5858
DEFAULT_ALLOW_TOOLS,
5959
] as const;
6060

61+
function formatErrorForCli(error: unknown): string {
62+
if (error instanceof ZodError) {
63+
return [...error.errors.map((e) => `- ${e.path.join(".") || "<root>"}: ${e.message}`)].join(
64+
"\n",
65+
);
66+
}
67+
68+
if (error instanceof Error) {
69+
return error.message;
70+
}
71+
72+
return "Unknown error";
73+
}
74+
6175
program
6276
.command("start-server", { isDefault: true })
6377
.description("Starts the Algolia MCP server")
6478
.option<string[]>(...ALLOW_TOOLS_OPTIONS_TUPLE)
65-
.action(async (opts: StartServerOptions) => {
66-
const { startServer } = await import("./commands/start-server.ts");
67-
await startServer(opts);
79+
.option(
80+
"--credentials <applicationId:apiKey>",
81+
"Application ID and associated API key to use. Optional: the MCP will authenticate you if unspecified, giving you access to all your applications.",
82+
(val) => {
83+
const [applicationId, apiKey] = val.split(":");
84+
if (!applicationId || !apiKey) {
85+
throw new Error("Invalid credentials format. Use applicationId:apiKey");
86+
}
87+
return { applicationId, apiKey };
88+
},
89+
)
90+
.action(async (opts) => {
91+
try {
92+
const { startServer } = await import("./commands/start-server.ts");
93+
await startServer(opts);
94+
} catch (error) {
95+
console.error(formatErrorForCli(error));
96+
process.exit(1);
97+
}
6898
});
6999

70100
program

src/commands/start-server.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3+
4+
import { startServer } from "./start-server.ts";
5+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
6+
import { setupServer } from "msw/node";
7+
import { http } from "msw";
8+
import { ZodError } from "zod";
9+
import type { AppState } from "../appState.ts";
10+
import { AppStateManager } from "../appState.ts";
11+
import { REQUIRED_ACLS } from "../DashboardApi.ts";
12+
13+
const mswServer = setupServer();
14+
15+
beforeAll(() => mswServer.listen());
16+
afterEach(() => mswServer.resetHandlers());
17+
afterAll(() => mswServer.close());
18+
19+
describe("when specifying credentials flag", () => {
20+
it("should throw if params are missing", async () => {
21+
await expect(
22+
startServer({
23+
// @ts-expect-error -- I'm testing missing params
24+
credentials: { applicationId: "appId" },
25+
}),
26+
).rejects.toThrow(ZodError);
27+
await expect(
28+
startServer({
29+
// @ts-expect-error -- I'm testing missing params
30+
credentials: { apiKey: "apiKey" },
31+
}),
32+
).rejects.toThrow(ZodError);
33+
});
34+
35+
it("should not throw if both params are provided", async () => {
36+
vi.spyOn(AppStateManager, "load").mockRejectedValue(new Error("Should not be called"));
37+
const server = await startServer({ credentials: { applicationId: "appId", apiKey: "apiKey" } });
38+
39+
expect(AppStateManager.load).not.toHaveBeenCalled();
40+
41+
await server.close();
42+
});
43+
44+
it("should allow filtering tools", async () => {
45+
mswServer.use(
46+
http.put("https://appid.algolia.net/1/indexes/indexName/settings", () =>
47+
Response.json({ taskId: 123 }),
48+
),
49+
);
50+
const client = new Client({ name: "test client", version: "1.0.0" });
51+
const server = await startServer({
52+
credentials: {
53+
apiKey: "apiKey",
54+
applicationId: "appId",
55+
},
56+
allowTools: ["setSettings"],
57+
});
58+
59+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
60+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
61+
62+
const { tools } = await client.listTools();
63+
64+
expect(tools).toHaveLength(1);
65+
expect(tools[0].name).toBe("setSettings");
66+
67+
const result = await client.callTool({
68+
name: "setSettings",
69+
arguments: {
70+
indexName: "indexName",
71+
requestBody: {
72+
searchableAttributes: ["title"],
73+
},
74+
},
75+
});
76+
77+
expect(result).toMatchInlineSnapshot(`
78+
{
79+
"content": [
80+
{
81+
"text": "{"taskId":123}",
82+
"type": "text",
83+
},
84+
],
85+
}
86+
`);
87+
88+
await server.close();
89+
});
90+
});
91+
92+
describe("default behavior", () => {
93+
beforeEach(() => {
94+
const mockAppState: AppState = {
95+
accessToken: "accessToken",
96+
refreshToken: "refreshToken",
97+
apiKeys: {
98+
appId: "apiKey",
99+
},
100+
};
101+
vi.spyOn(AppStateManager, "load").mockResolvedValue(
102+
// @ts-expect-error -- It's just a partial mock
103+
{
104+
get: vi.fn(<K extends keyof AppState>(k: K) => mockAppState[k]),
105+
update: vi.fn(),
106+
},
107+
);
108+
});
109+
110+
it("should list dashboard tools", async () => {
111+
const client = new Client({ name: "test client", version: "1.0.0" });
112+
const server = await startServer({});
113+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
114+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
115+
116+
expect(AppStateManager.load).toHaveBeenCalled();
117+
118+
const { tools } = await client.listTools();
119+
expect(tools).toHaveLength(176);
120+
expect(tools.some((t) => t.name === "getUserInfo")).toBe(true);
121+
});
122+
123+
it("should fetch the api key automatically", async () => {
124+
mswServer.use(
125+
http.get("https://appid-dsn.algolia.net/1/keys/apiKey", () =>
126+
Response.json({ acl: REQUIRED_ACLS }),
127+
),
128+
http.get("https://appid.algolia.net/1/indexes/indexName/settings", () => Response.json({})),
129+
);
130+
const client = new Client({ name: "test client", version: "1.0.0" });
131+
const server = await startServer({ allowTools: ["getSettings"] });
132+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
133+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
134+
135+
const result = await client.callTool({
136+
name: "getSettings",
137+
arguments: {
138+
applicationId: "appId",
139+
indexName: "indexName",
140+
},
141+
});
142+
143+
expect(result).toMatchInlineSnapshot(`
144+
{
145+
"content": [
146+
{
147+
"text": "{}",
148+
"type": "text",
149+
},
150+
],
151+
}
152+
`);
153+
});
154+
});

0 commit comments

Comments
 (0)