Skip to content

Commit 573e5fd

Browse files
feat(ai-proxy): add Snowflake integration with PAT authentication (#1573)
* feat(ai-proxy): add Snowflake integration with PAT authentication Exposes 3 MCP tools backed by Snowflake REST API v2 (Cortex Search, Cortex Analyst, read-only SQL execution), authenticated via Programmatic Access Tokens. The execute-query tool enforces a defense-in-depth read-only SQL guard on top of Snowflake role privileges. --------- Co-authored-by: Brun Christophe <christophe.brun@forestadmin.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 07fc2b6 commit 573e5fd

12 files changed

Lines changed: 1018 additions & 2 deletions

File tree

packages/ai-proxy/src/forest-integration-client.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import type { Logger } from '@forestadmin/datasource-toolkit';
55
import { AIBadRequestError } from './errors';
66
import getKolarTools, { type KolarConfig } from './integrations/kolar/tools';
77
import { validateKolarConfig } from './integrations/kolar/utils';
8+
import getSnowflakeTools, { type SnowflakeConfig } from './integrations/snowflake/tools';
9+
import { validateSnowflakeConfig } from './integrations/snowflake/utils';
810
import getZendeskTools, { type ZendeskConfig } from './integrations/zendesk/tools';
911
import { validateZendeskConfig } from './integrations/zendesk/utils';
1012

11-
export type CustomConfig = ZendeskConfig | KolarConfig;
12-
export type ForestIntegrationName = 'Zendesk' | 'Kolar';
13+
export type CustomConfig = ZendeskConfig | KolarConfig | SnowflakeConfig;
14+
export type ForestIntegrationName = 'Zendesk' | 'Kolar' | 'Snowflake';
1315

1416
export interface ForestIntegrationConfig {
1517
integrationName: ForestIntegrationName;
@@ -45,6 +47,9 @@ export default class ForestIntegrationClient implements ToolProvider {
4547
case 'Kolar':
4648
tools.push(...getKolarTools(config as KolarConfig));
4749
break;
50+
case 'Snowflake':
51+
tools.push(...getSnowflakeTools(config as SnowflakeConfig));
52+
break;
4853
default:
4954
this.logger?.('Warn', `Unsupported integration: ${integrationName}`);
5055
}
@@ -61,6 +66,8 @@ export default class ForestIntegrationClient implements ToolProvider {
6166
return validateZendeskConfig(config as ZendeskConfig);
6267
case 'Kolar':
6368
return validateKolarConfig(config as KolarConfig);
69+
case 'Snowflake':
70+
return validateSnowflakeConfig(config as SnowflakeConfig);
6471
default:
6572
throw new AIBadRequestError(`Unsupported integration: ${integrationName}`);
6673
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type RemoteTool from '../../remote-tool';
2+
3+
import ServerRemoteTool from '../../server-remote-tool';
4+
import createCortexAnalystTool from './tools/cortex-analyst';
5+
import createCortexSearchTool from './tools/cortex-search';
6+
import createExecuteQueryTool from './tools/execute-query';
7+
import { getSnowflakeAuthHeaders, getSnowflakeBaseUrl } from './utils';
8+
9+
export interface SnowflakeConfig {
10+
accountIdentifier: string;
11+
programmaticAccessToken: string;
12+
defaultWarehouse?: string;
13+
defaultDatabase?: string;
14+
defaultSchema?: string;
15+
defaultRole?: string;
16+
}
17+
18+
export default function getSnowflakeTools(config: SnowflakeConfig): RemoteTool[] {
19+
const headers = getSnowflakeAuthHeaders(config);
20+
const baseUrl = getSnowflakeBaseUrl(config.accountIdentifier);
21+
22+
return [
23+
createCortexSearchTool(headers, baseUrl),
24+
createCortexAnalystTool(headers, baseUrl),
25+
createExecuteQueryTool(headers, baseUrl, config),
26+
].map(
27+
tool =>
28+
new ServerRemoteTool({
29+
sourceId: 'snowflake',
30+
tool,
31+
}),
32+
);
33+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { DynamicStructuredTool } from '@langchain/core/tools';
2+
import { z } from 'zod';
3+
4+
import { AIBadRequestError } from '../../../errors';
5+
import { assertResponseOk } from '../utils';
6+
7+
export default function createCortexAnalystTool(
8+
headers: Record<string, string>,
9+
baseUrl: string,
10+
): DynamicStructuredTool {
11+
return new DynamicStructuredTool({
12+
name: 'snowflake_cortex_analyst',
13+
description:
14+
'Ask a natural-language analytical question against a Snowflake semantic model or ' +
15+
'semantic view using Cortex Analyst. Returns a generated SQL query and the answer. ' +
16+
'Exactly one of `semantic_model_file` or `semantic_view` must be provided.',
17+
schema: z.object({
18+
question: z.string().min(1).describe('The natural-language analytical question'),
19+
semantic_model_file: z
20+
.string()
21+
.optional()
22+
.describe(
23+
'Stage path to the YAML semantic model file ' +
24+
'(e.g. "@my_db.my_schema.my_stage/model.yaml")',
25+
),
26+
semantic_view: z
27+
.string()
28+
.optional()
29+
.describe('Fully qualified semantic view name (e.g. "my_db.my_schema.my_view")'),
30+
}),
31+
func: async ({ question, semantic_model_file, semantic_view }) => {
32+
if (!semantic_model_file && !semantic_view) {
33+
throw new AIBadRequestError(
34+
'Either `semantic_model_file` or `semantic_view` must be provided.',
35+
);
36+
}
37+
38+
if (semantic_model_file && semantic_view) {
39+
throw new AIBadRequestError(
40+
'Provide only one of `semantic_model_file` or `semantic_view`, not both.',
41+
);
42+
}
43+
44+
const body: Record<string, unknown> = {
45+
messages: [
46+
{
47+
role: 'user',
48+
content: [{ type: 'text', text: question }],
49+
},
50+
],
51+
...(semantic_model_file && { semantic_model_file }),
52+
...(semantic_view && { semantic_view }),
53+
};
54+
55+
const response = await fetch(`${baseUrl}/api/v2/cortex/analyst/message`, {
56+
method: 'POST',
57+
headers,
58+
body: JSON.stringify(body),
59+
});
60+
61+
await assertResponseOk(response, 'cortex analyst');
62+
63+
return JSON.stringify(await response.json());
64+
},
65+
});
66+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { DynamicStructuredTool } from '@langchain/core/tools';
2+
import { z } from 'zod';
3+
4+
import { assertResponseOk } from '../utils';
5+
6+
export default function createCortexSearchTool(
7+
headers: Record<string, string>,
8+
baseUrl: string,
9+
): DynamicStructuredTool {
10+
return new DynamicStructuredTool({
11+
name: 'snowflake_cortex_search',
12+
description:
13+
'Search data using a Snowflake Cortex Search service. ' +
14+
'Returns semantic search results ranked by relevance. ' +
15+
'The caller must provide the fully qualified service identifier (database, schema, service).',
16+
schema: z.object({
17+
database: z.string().min(1).describe('Name of the database containing the search service'),
18+
schema: z.string().min(1).describe('Name of the schema containing the search service'),
19+
service: z.string().min(1).describe('Name of the Cortex Search service'),
20+
query: z.string().min(1).describe('The natural-language search query'),
21+
columns: z
22+
.array(z.string().min(1))
23+
.optional()
24+
.describe('Columns to include in the search results'),
25+
filter: z
26+
.record(z.string(), z.unknown())
27+
.optional()
28+
.describe('Optional filter expression as a JSON object'),
29+
limit: z
30+
.number()
31+
.int()
32+
.positive()
33+
.max(1000)
34+
.optional()
35+
.describe('Maximum number of results to return (default: service-defined)'),
36+
}),
37+
func: async ({ database, schema, service, query, columns, filter, limit }) => {
38+
const url =
39+
`${baseUrl}/api/v2/databases/${encodeURIComponent(database)}` +
40+
`/schemas/${encodeURIComponent(schema)}` +
41+
`/cortex-search-services/${encodeURIComponent(service)}:query`;
42+
43+
const body: Record<string, unknown> = { query };
44+
if (columns) body.columns = columns;
45+
if (filter) body.filter = filter;
46+
if (limit !== undefined) body.limit = limit;
47+
48+
const response = await fetch(url, {
49+
method: 'POST',
50+
headers,
51+
body: JSON.stringify(body),
52+
});
53+
54+
await assertResponseOk(response, 'cortex search');
55+
56+
return JSON.stringify(await response.json());
57+
},
58+
});
59+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { SnowflakeConfig } from '../tools';
2+
3+
import { DynamicStructuredTool } from '@langchain/core/tools';
4+
import { z } from 'zod';
5+
6+
import { assertReadOnlySql, assertResponseOk, buildSessionContext } from '../utils';
7+
8+
export default function createExecuteQueryTool(
9+
headers: Record<string, string>,
10+
baseUrl: string,
11+
config: SnowflakeConfig,
12+
): DynamicStructuredTool {
13+
return new DynamicStructuredTool({
14+
name: 'snowflake_execute_query',
15+
description:
16+
'Execute a read-only SQL query on Snowflake and return the results. ' +
17+
'Only SELECT, SHOW, DESCRIBE, and EXPLAIN statements are allowed. ' +
18+
'Multiple statements are not permitted.',
19+
schema: z.object({
20+
statement: z.string().min(1).describe('The read-only SQL statement to execute'),
21+
warehouse: z
22+
.string()
23+
.optional()
24+
.describe('Warehouse to use for this query (overrides the default)'),
25+
database: z
26+
.string()
27+
.optional()
28+
.describe('Database to use for this query (overrides the default)'),
29+
schema: z
30+
.string()
31+
.optional()
32+
.describe('Schema to use for this query (overrides the default)'),
33+
role: z.string().optional().describe('Role to use for this query (overrides the default)'),
34+
}),
35+
func: async ({ statement, warehouse, database, schema, role }) => {
36+
assertReadOnlySql(statement);
37+
38+
const body = {
39+
statement,
40+
...buildSessionContext(config),
41+
...(warehouse && { warehouse }),
42+
...(database && { database }),
43+
...(schema && { schema }),
44+
...(role && { role }),
45+
};
46+
47+
const response = await fetch(`${baseUrl}/api/v2/statements`, {
48+
method: 'POST',
49+
headers,
50+
body: JSON.stringify(body),
51+
});
52+
53+
await assertResponseOk(response, 'execute query');
54+
55+
return JSON.stringify(await response.json());
56+
},
57+
});
58+
}

0 commit comments

Comments
 (0)