Skip to content

Commit dcc2f12

Browse files
committed
feat: add VoyageAI embeddings/rerank integration and MongoDB Atlas connection string support
- Add VoyageAI tools: embeddings (voyage-3, voyage-3-large, etc.) and rerank (rerank-2, rerank-2-lite) - Add VoyageAI block with operation dropdown (Generate Embeddings / Rerank) - Add VoyageAI icon and register in tool/block registries - Enhance MongoDB with connection string mode for Atlas (mongodb+srv://) support - Add connection mode toggle to MongoDB block (Host & Port / Connection String) - Update all 6 MongoDB API routes to accept optional connectionString - Add 48 unit tests (VoyageAI tools, block config, MongoDB utils)
1 parent 19442f1 commit dcc2f12

26 files changed

Lines changed: 1150 additions & 28 deletions

File tree

apps/sim/app/api/tools/mongodb/delete/route.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from
88
const logger = createLogger('MongoDBDeleteAPI')
99

1010
const DeleteSchema = z.object({
11-
host: z.string().min(1, 'Host is required'),
12-
port: z.coerce.number().int().positive('Port must be a positive integer'),
11+
connectionString: z.string().optional(),
12+
host: z.string().default(''),
13+
port: z.coerce.number().int().nonnegative().default(27017),
1314
database: z.string().min(1, 'Database name is required'),
14-
username: z.string().min(1, 'Username is required'),
15-
password: z.string().min(1, 'Password is required'),
15+
username: z.string().default(''),
16+
password: z.string().default(''),
1617
authSource: z.string().optional(),
1718
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
1819
collection: z.string().min(1, 'Collection name is required'),
@@ -75,6 +76,7 @@ export async function POST(request: NextRequest) {
7576
}
7677

7778
client = await createMongoDBConnection({
79+
connectionString: params.connectionString,
7880
host: params.host,
7981
port: params.port,
8082
database: params.database,

apps/sim/app/api/tools/mongodb/execute/route.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import { createMongoDBConnection, sanitizeCollectionName, validatePipeline } fro
88
const logger = createLogger('MongoDBExecuteAPI')
99

1010
const ExecuteSchema = z.object({
11-
host: z.string().min(1, 'Host is required'),
12-
port: z.coerce.number().int().positive('Port must be a positive integer'),
11+
connectionString: z.string().optional(),
12+
host: z.string().default(''),
13+
port: z.coerce.number().int().nonnegative().default(27017),
1314
database: z.string().min(1, 'Database name is required'),
14-
username: z.string().min(1, 'Username is required'),
15-
password: z.string().min(1, 'Password is required'),
15+
username: z.string().default(''),
16+
password: z.string().default(''),
1617
authSource: z.string().optional(),
1718
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
1819
collection: z.string().min(1, 'Collection name is required'),
@@ -61,6 +62,7 @@ export async function POST(request: NextRequest) {
6162
const pipelineDoc = JSON.parse(params.pipeline)
6263

6364
client = await createMongoDBConnection({
65+
connectionString: params.connectionString,
6466
host: params.host,
6567
port: params.port,
6668
database: params.database,

apps/sim/app/api/tools/mongodb/insert/route.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import { createMongoDBConnection, sanitizeCollectionName } from '../utils'
88
const logger = createLogger('MongoDBInsertAPI')
99

1010
const InsertSchema = z.object({
11-
host: z.string().min(1, 'Host is required'),
12-
port: z.coerce.number().int().positive('Port must be a positive integer'),
11+
connectionString: z.string().optional(),
12+
host: z.string().default(''),
13+
port: z.coerce.number().int().nonnegative().default(27017),
1314
database: z.string().min(1, 'Database name is required'),
14-
username: z.string().min(1, 'Username is required'),
15-
password: z.string().min(1, 'Password is required'),
15+
username: z.string().default(''),
16+
password: z.string().default(''),
1617
authSource: z.string().optional(),
1718
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
1819
collection: z.string().min(1, 'Collection name is required'),
@@ -54,6 +55,7 @@ export async function POST(request: NextRequest) {
5455

5556
const sanitizedCollection = sanitizeCollectionName(params.collection)
5657
client = await createMongoDBConnection({
58+
connectionString: params.connectionString,
5759
host: params.host,
5860
port: params.port,
5961
database: params.database,

apps/sim/app/api/tools/mongodb/introspect/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import { createMongoDBConnection, executeIntrospect } from '../utils'
88
const logger = createLogger('MongoDBIntrospectAPI')
99

1010
const IntrospectSchema = z.object({
11-
host: z.string().min(1, 'Host is required'),
12-
port: z.coerce.number().int().positive('Port must be a positive integer'),
11+
connectionString: z.string().optional(),
12+
host: z.string().default(''),
13+
port: z.coerce.number().int().nonnegative().default(27017),
1314
database: z.string().optional(),
1415
username: z.string().optional(),
1516
password: z.string().optional(),
@@ -36,6 +37,7 @@ export async function POST(request: NextRequest) {
3637
)
3738

3839
client = await createMongoDBConnection({
40+
connectionString: params.connectionString,
3941
host: params.host,
4042
port: params.port,
4143
database: params.database || 'admin',

apps/sim/app/api/tools/mongodb/query/route.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from
88
const logger = createLogger('MongoDBQueryAPI')
99

1010
const QuerySchema = z.object({
11-
host: z.string().min(1, 'Host is required'),
12-
port: z.coerce.number().int().positive('Port must be a positive integer'),
11+
connectionString: z.string().optional(),
12+
host: z.string().default(''),
13+
port: z.coerce.number().int().nonnegative().default(27017),
1314
database: z.string().min(1, 'Database name is required'),
14-
username: z.string().min(1, 'Username is required'),
15-
password: z.string().min(1, 'Password is required'),
15+
username: z.string().default(''),
16+
password: z.string().default(''),
1617
authSource: z.string().optional(),
1718
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
1819
collection: z.string().min(1, 'Collection name is required'),
@@ -90,6 +91,7 @@ export async function POST(request: NextRequest) {
9091
}
9192

9293
client = await createMongoDBConnection({
94+
connectionString: params.connectionString,
9395
host: params.host,
9496
port: params.port,
9597
database: params.database,

apps/sim/app/api/tools/mongodb/update/route.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from
88
const logger = createLogger('MongoDBUpdateAPI')
99

1010
const UpdateSchema = z.object({
11-
host: z.string().min(1, 'Host is required'),
12-
port: z.coerce.number().int().positive('Port must be a positive integer'),
11+
connectionString: z.string().optional(),
12+
host: z.string().default(''),
13+
port: z.coerce.number().int().nonnegative().default(27017),
1314
database: z.string().min(1, 'Database name is required'),
14-
username: z.string().min(1, 'Username is required'),
15-
password: z.string().min(1, 'Password is required'),
15+
username: z.string().default(''),
16+
password: z.string().default(''),
1617
authSource: z.string().optional(),
1718
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
1819
collection: z.string().min(1, 'Collection name is required'),
@@ -99,6 +100,7 @@ export async function POST(request: NextRequest) {
99100
}
100101

101102
client = await createMongoDBConnection({
103+
connectionString: params.connectionString,
102104
host: params.host,
103105
port: params.port,
104106
database: params.database,
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockConnect, mockMongoClient, mockValidateDatabaseHost } = vi.hoisted(() => {
7+
const mockConnect = vi.fn().mockResolvedValue(undefined)
8+
const mockMongoClient = vi.fn().mockImplementation(() => ({
9+
connect: mockConnect,
10+
db: vi.fn(),
11+
close: vi.fn(),
12+
}))
13+
const mockValidateDatabaseHost = vi.fn().mockResolvedValue({ isValid: true })
14+
return { mockConnect, mockMongoClient, mockValidateDatabaseHost }
15+
})
16+
17+
vi.mock('mongodb', () => ({
18+
MongoClient: mockMongoClient,
19+
}))
20+
21+
vi.mock('@/lib/core/security/input-validation.server', () => ({
22+
validateDatabaseHost: mockValidateDatabaseHost,
23+
}))
24+
25+
import {
26+
createMongoDBConnection,
27+
sanitizeCollectionName,
28+
validateFilter,
29+
validatePipeline,
30+
} from '@/app/api/tools/mongodb/utils'
31+
32+
describe('MongoDB Utils', () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks()
35+
mockValidateDatabaseHost.mockResolvedValue({ isValid: true })
36+
})
37+
38+
describe('createMongoDBConnection', () => {
39+
it('should use connectionString directly when provided', async () => {
40+
await createMongoDBConnection({
41+
connectionString: 'mongodb+srv://user:pass@cluster.mongodb.net/mydb',
42+
host: '',
43+
port: 27017,
44+
database: 'mydb',
45+
})
46+
47+
expect(mockMongoClient).toHaveBeenCalledWith(
48+
'mongodb+srv://user:pass@cluster.mongodb.net/mydb',
49+
expect.objectContaining({
50+
connectTimeoutMS: 10000,
51+
socketTimeoutMS: 10000,
52+
maxPoolSize: 1,
53+
})
54+
)
55+
expect(mockValidateDatabaseHost).not.toHaveBeenCalled()
56+
expect(mockConnect).toHaveBeenCalled()
57+
})
58+
59+
it('should build URI from host/port when no connectionString', async () => {
60+
await createMongoDBConnection({
61+
host: 'localhost',
62+
port: 27017,
63+
database: 'testdb',
64+
username: 'user',
65+
password: 'pass',
66+
})
67+
68+
expect(mockValidateDatabaseHost).toHaveBeenCalledWith('localhost', 'host')
69+
expect(mockMongoClient).toHaveBeenCalledWith(
70+
expect.stringContaining('mongodb://user:pass@localhost:27017/testdb'),
71+
expect.any(Object)
72+
)
73+
})
74+
75+
it('should skip host validation when connectionString is provided', async () => {
76+
await createMongoDBConnection({
77+
connectionString: 'mongodb+srv://test@cluster.net/db',
78+
host: '',
79+
port: 0,
80+
database: 'db',
81+
})
82+
83+
expect(mockValidateDatabaseHost).not.toHaveBeenCalled()
84+
})
85+
86+
it('should apply connection options with connectionString', async () => {
87+
await createMongoDBConnection({
88+
connectionString: 'mongodb+srv://test@cluster.net/db',
89+
host: '',
90+
port: 0,
91+
database: 'db',
92+
})
93+
94+
expect(mockMongoClient).toHaveBeenCalledWith(
95+
expect.any(String),
96+
expect.objectContaining({
97+
connectTimeoutMS: 10000,
98+
socketTimeoutMS: 10000,
99+
maxPoolSize: 1,
100+
})
101+
)
102+
})
103+
104+
it('should include authSource in URI when provided', async () => {
105+
await createMongoDBConnection({
106+
host: 'localhost',
107+
port: 27017,
108+
database: 'testdb',
109+
authSource: 'admin',
110+
})
111+
112+
expect(mockMongoClient).toHaveBeenCalledWith(
113+
expect.stringContaining('authSource=admin'),
114+
expect.any(Object)
115+
)
116+
})
117+
118+
it('should include ssl in URI when required', async () => {
119+
await createMongoDBConnection({
120+
host: 'localhost',
121+
port: 27017,
122+
database: 'testdb',
123+
ssl: 'required',
124+
})
125+
126+
expect(mockMongoClient).toHaveBeenCalledWith(
127+
expect.stringContaining('ssl=true'),
128+
expect.any(Object)
129+
)
130+
})
131+
132+
it('should throw when host validation fails and no connectionString', async () => {
133+
mockValidateDatabaseHost.mockResolvedValue({
134+
isValid: false,
135+
error: 'Invalid host',
136+
})
137+
138+
await expect(
139+
createMongoDBConnection({
140+
host: 'bad-host',
141+
port: 27017,
142+
database: 'testdb',
143+
})
144+
).rejects.toThrow('Invalid host')
145+
})
146+
})
147+
148+
describe('validateFilter', () => {
149+
it('should accept valid filters', () => {
150+
expect(validateFilter('{"status": "active"}')).toEqual({ isValid: true })
151+
})
152+
153+
it('should reject dangerous operators', () => {
154+
const result = validateFilter('{"$where": "this.a > 1"}')
155+
expect(result.isValid).toBe(false)
156+
expect(result.error).toContain('dangerous operators')
157+
})
158+
159+
it('should reject invalid JSON', () => {
160+
const result = validateFilter('not json')
161+
expect(result.isValid).toBe(false)
162+
expect(result.error).toContain('Invalid JSON')
163+
})
164+
})
165+
166+
describe('validatePipeline', () => {
167+
it('should accept valid pipelines', () => {
168+
expect(validatePipeline('[{"$match": {"status": "active"}}]')).toEqual({ isValid: true })
169+
})
170+
171+
it('should allow $vectorSearch stage', () => {
172+
const pipeline = JSON.stringify([
173+
{
174+
$vectorSearch: {
175+
index: 'vector_index',
176+
path: 'embedding',
177+
queryVector: [0.1, 0.2, 0.3],
178+
numCandidates: 100,
179+
limit: 10,
180+
},
181+
},
182+
])
183+
expect(validatePipeline(pipeline)).toEqual({ isValid: true })
184+
})
185+
186+
it('should reject dangerous pipeline operators', () => {
187+
const result = validatePipeline('[{"$merge": {"into": "other_collection"}}]')
188+
expect(result.isValid).toBe(false)
189+
})
190+
191+
it('should reject non-array pipelines', () => {
192+
const result = validatePipeline('{"$match": {}}')
193+
expect(result.isValid).toBe(false)
194+
expect(result.error).toContain('must be an array')
195+
})
196+
})
197+
198+
describe('sanitizeCollectionName', () => {
199+
it('should accept valid collection names', () => {
200+
expect(sanitizeCollectionName('users')).toBe('users')
201+
expect(sanitizeCollectionName('my_collection')).toBe('my_collection')
202+
expect(sanitizeCollectionName('_private')).toBe('_private')
203+
})
204+
205+
it('should reject invalid collection names', () => {
206+
expect(() => sanitizeCollectionName('invalid-name')).toThrow('Invalid collection name')
207+
expect(() => sanitizeCollectionName('123start')).toThrow('Invalid collection name')
208+
expect(() => sanitizeCollectionName('has space')).toThrow('Invalid collection name')
209+
})
210+
})
211+
})

apps/sim/app/api/tools/mongodb/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ import { validateDatabaseHost } from '@/lib/core/security/input-validation.serve
33
import type { MongoDBCollectionInfo, MongoDBConnectionConfig } from '@/tools/mongodb/types'
44

55
export async function createMongoDBConnection(config: MongoDBConnectionConfig) {
6+
if (config.connectionString) {
7+
const client = new MongoClient(config.connectionString, {
8+
connectTimeoutMS: 10000,
9+
socketTimeoutMS: 10000,
10+
maxPoolSize: 1,
11+
})
12+
await client.connect()
13+
return client
14+
}
15+
616
const hostValidation = await validateDatabaseHost(config.host, 'host')
717
if (!hostValidation.isValid) {
818
throw new Error(hostValidation.error)

0 commit comments

Comments
 (0)