Skip to content

Commit 298adf4

Browse files
feat: Add RunPipeline tool (#253)
1 parent b6ff2cc commit 298adf4

File tree

5 files changed

+217
-2
lines changed

5 files changed

+217
-2
lines changed

src/server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Session } from "./session.js";
33
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
44
import { AtlasTools } from "./tools/atlas/tools.js";
55
import { MongoDbTools } from "./tools/mongodb/tools.js";
6+
import { PlaygroundTools } from "./tools/playground/tools.js";
67
import logger, { initializeLogger, LogId } from "./logger.js";
78
import { ObjectId } from "mongodb";
89
import { Telemetry } from "./telemetry/telemetry.js";
@@ -134,7 +135,7 @@ export class Server {
134135
}
135136

136137
private registerTools() {
137-
for (const tool of [...AtlasTools, ...MongoDbTools]) {
138+
for (const tool of [...AtlasTools, ...MongoDbTools, ...PlaygroundTools]) {
138139
new tool(this.session, this.userConfig, this.telemetry).register(this.mcpServer);
139140
}
140141
}

src/tools/playground/runPipeline.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { OperationType, TelemetryToolMetadata, ToolArgs, ToolBase, ToolCategory } from "../tool.js";
2+
import { z } from "zod";
3+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
4+
import { EJSON } from "bson";
5+
6+
const PLAYGROUND_SEARCH_URL = "https://search-playground.mongodb.com/api/tools/code-playground/search";
7+
8+
const DEFAULT_DOCUMENTS = [
9+
{
10+
name: "First document",
11+
},
12+
{
13+
name: "Second document",
14+
},
15+
];
16+
17+
const DEFAULT_SEARCH_INDEX_DEFINITION = {
18+
mappings: {
19+
dynamic: true,
20+
},
21+
};
22+
23+
const DEFAULT_PIPELINE = [
24+
{
25+
$search: {
26+
index: "default",
27+
text: {
28+
query: "first",
29+
path: {
30+
wildcard: "*",
31+
},
32+
},
33+
},
34+
},
35+
];
36+
37+
const DEFAULT_SYNONYMS: Array<Record<string, unknown>> = [];
38+
39+
export const RunPipelineOperationArgs = {
40+
documents: z
41+
.array(z.record(z.string(), z.unknown()))
42+
.max(500)
43+
.describe("Documents to run the pipeline against. 500 is maximum.")
44+
.default(DEFAULT_DOCUMENTS),
45+
aggregationPipeline: z
46+
.array(z.record(z.string(), z.unknown()))
47+
.describe("MongoDB aggregation pipeline to run on the provided documents.")
48+
.default(DEFAULT_PIPELINE),
49+
searchIndexDefinition: z
50+
.record(z.string(), z.unknown())
51+
.describe("MongoDB search index definition to create before running the pipeline.")
52+
.optional()
53+
.default(DEFAULT_SEARCH_INDEX_DEFINITION),
54+
synonyms: z
55+
.array(z.record(z.any()))
56+
.describe("MongoDB synonyms mapping to create before running the pipeline.")
57+
.optional()
58+
.default(DEFAULT_SYNONYMS),
59+
};
60+
61+
interface RunRequest {
62+
documents: string;
63+
aggregationPipeline: string;
64+
indexDefinition: string;
65+
synonyms: string;
66+
}
67+
68+
interface RunResponse {
69+
documents: Array<Record<string, unknown>>;
70+
}
71+
72+
interface RunErrorResponse {
73+
code: string;
74+
message: string;
75+
}
76+
77+
export class RunPipeline extends ToolBase {
78+
protected name = "run-pipeline";
79+
protected description =
80+
"Run MongoDB aggregation pipeline for provided documents without needing an Atlas account, cluster, or collection. The tool can be useful for running ad-hoc pipelines for testing or debugging.";
81+
protected category: ToolCategory = "playground";
82+
protected operationType: OperationType = "metadata";
83+
protected argsShape = RunPipelineOperationArgs;
84+
85+
protected async execute(toolArgs: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
86+
const runRequest = this.convertToRunRequest(toolArgs);
87+
const runResponse = await this.runPipeline(runRequest);
88+
const toolResult = this.convertToToolResult(runResponse);
89+
return toolResult;
90+
}
91+
92+
protected resolveTelemetryMetadata(): TelemetryToolMetadata {
93+
return {};
94+
}
95+
96+
private async runPipeline(runRequest: RunRequest): Promise<RunResponse> {
97+
const options: RequestInit = {
98+
method: "POST",
99+
headers: {
100+
"Content-Type": "application/json",
101+
},
102+
body: JSON.stringify(runRequest),
103+
};
104+
105+
let response: Response;
106+
try {
107+
response = await fetch(PLAYGROUND_SEARCH_URL, options);
108+
} catch {
109+
throw new Error("Cannot run pipeline: network error.");
110+
}
111+
112+
if (!response.ok) {
113+
const errorMessage = await this.getPlaygroundResponseError(response);
114+
throw new Error(`Pipeline run failed: ${errorMessage}`);
115+
}
116+
117+
try {
118+
return (await response.json()) as RunResponse;
119+
} catch {
120+
throw new Error("Pipeline run failed: response is not valid JSON.");
121+
}
122+
}
123+
124+
private async getPlaygroundResponseError(response: Response): Promise<string> {
125+
let errorMessage = `HTTP ${response.status} ${response.statusText}.`;
126+
try {
127+
const errorResponse = (await response.json()) as RunErrorResponse;
128+
errorMessage += ` Error code: ${errorResponse.code}. Error message: ${errorResponse.message}`;
129+
} catch {
130+
// Ignore JSON parse errors
131+
}
132+
133+
return errorMessage;
134+
}
135+
136+
private convertToRunRequest(toolArgs: ToolArgs<typeof this.argsShape>): RunRequest {
137+
try {
138+
return {
139+
documents: JSON.stringify(toolArgs.documents),
140+
aggregationPipeline: JSON.stringify(toolArgs.aggregationPipeline),
141+
indexDefinition: JSON.stringify(toolArgs.searchIndexDefinition || DEFAULT_SEARCH_INDEX_DEFINITION),
142+
synonyms: JSON.stringify(toolArgs.synonyms || DEFAULT_SYNONYMS),
143+
};
144+
} catch {
145+
throw new Error("Invalid arguments type.");
146+
}
147+
}
148+
149+
private convertToToolResult(runResponse: RunResponse): CallToolResult {
150+
const content: Array<{ text: string; type: "text" }> = [
151+
{
152+
text: `Found ${runResponse.documents.length} documents":`,
153+
type: "text",
154+
},
155+
...runResponse.documents.map((doc) => {
156+
return {
157+
text: EJSON.stringify(doc),
158+
type: "text",
159+
} as { text: string; type: "text" };
160+
}),
161+
];
162+
163+
return {
164+
content,
165+
};
166+
}
167+
}

src/tools/playground/tools.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { RunPipeline } from "./runPipeline.js";
2+
3+
export const PlaygroundTools = [RunPipeline];

src/tools/tool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { UserConfig } from "../config.js";
1010
export type ToolArgs<Args extends ZodRawShape> = z.objectOutputType<Args, ZodNever>;
1111

1212
export type OperationType = "metadata" | "read" | "create" | "delete" | "update";
13-
export type ToolCategory = "mongodb" | "atlas";
13+
export type ToolCategory = "mongodb" | "atlas" | "playground";
1414
export type TelemetryToolMetadata = {
1515
projectId?: string;
1616
orgId?: string;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describeWithMongoDB } from "../mongodb/mongodbHelpers.js";
2+
import { getResponseElements } from "../../helpers.js";
3+
4+
describeWithMongoDB("runPipeline tool", (integration) => {
5+
it("should return results", async () => {
6+
await integration.connectMcpClient();
7+
const response = await integration.mcpClient().callTool({
8+
name: "run-pipeline",
9+
arguments: {
10+
documents: [{ name: "First document" }, { name: "Second document" }],
11+
aggregationPipeline: [
12+
{
13+
$search: {
14+
index: "default",
15+
text: {
16+
query: "first",
17+
path: {
18+
wildcard: "*",
19+
},
20+
},
21+
},
22+
},
23+
{
24+
$project: {
25+
_id: 0,
26+
name: 1,
27+
},
28+
},
29+
],
30+
},
31+
});
32+
const elements = getResponseElements(response.content);
33+
expect(elements).toEqual([
34+
{
35+
text: 'Found 1 documents":',
36+
type: "text",
37+
},
38+
{
39+
text: '{"name":"First document"}',
40+
type: "text",
41+
},
42+
]);
43+
});
44+
});

0 commit comments

Comments
 (0)