Skip to content

Commit c7901d8

Browse files
authored
Merge pull request #8 from railsware/CPL-19831/get-dataflow
[CPL-19831] `get-dataflow` tool
2 parents 206153a + a84e020 commit c7901d8

22 files changed

+769
-405
lines changed

package-lock.json

Lines changed: 440 additions & 335 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "coupler-io-mcp-server",
3-
"version": "0.0.1",
3+
"version": "0.0.3",
44
"main": "index.js",
55
"license": "MIT",
66
"homepage": "https://coupler.io",
@@ -14,30 +14,30 @@
1414
},
1515
"description": "Coupler.io MCP server",
1616
"dependencies": {
17-
"@modelcontextprotocol/sdk": "1.11.4",
17+
"@modelcontextprotocol/sdk": "1.12.1",
1818
"better-sqlite3": "11.10.0",
1919
"json-schema-to-zod": "2.6.1",
2020
"lodash": "4.17.21",
2121
"pino": "9.7.0",
2222
"tsx": "4.19.4",
2323
"url-template": "3.1.1",
2424
"znv": "0.5.0",
25-
"zod": "3.24.4",
25+
"zod": "3.25.46",
2626
"zod-to-json-schema": "3.24.5",
2727
"zod-validation-error": "3.4.1"
2828
},
2929
"devDependencies": {
30-
"@eslint/js": "9.27.0",
31-
"@modelcontextprotocol/inspector": "0.12.0",
30+
"@eslint/js": "9.28.0",
31+
"@modelcontextprotocol/inspector": "0.13.0",
3232
"@types/better-sqlite3": "7.6.13",
33-
"@types/lodash": "4.17.16",
34-
"@types/node": "22.15.19",
35-
"eslint": "9.27.0",
33+
"@types/lodash": "4.17.17",
34+
"@types/node": "22.15.29",
35+
"eslint": "9.28.0",
3636
"lefthook": "1.11.13",
3737
"pino-pretty": "13.0.0",
3838
"typescript": "5.8.3",
39-
"typescript-eslint": "8.32.1",
39+
"typescript-eslint": "8.33.0",
4040
"vite-tsconfig-paths": "5.1.4",
41-
"vitest": "3.1.3"
41+
"vitest": "3.1.4"
4242
}
4343
}

src/server/index.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,18 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
55
import * as getData from '@/tools/get-data'
66
import * as getSchema from '@/tools/get-schema'
77
import * as listDataflows from '@/tools/list-dataflows'
8+
import * as getDataflow from '@/tools/get-dataflow'
89

910
const TOOL_MAP = {
10-
[getData.name]: getData.toolMapEntry,
11-
[getSchema.name]: getSchema.toolMapEntry,
12-
[listDataflows.name]: listDataflows.toolMapEntry,
11+
[getData.name]: getData.handler,
12+
[getSchema.name]: getSchema.handler,
13+
[listDataflows.name]: listDataflows.handler,
14+
[getDataflow.name]: getDataflow.handler,
1315
}
1416

1517
export const server = new Server({
1618
name: 'Coupler.io MCP server',
17-
version: '0.0.1',
19+
version: '0.0.3',
1820
}, {
1921
capabilities: {
2022
tools: {},
@@ -24,12 +26,12 @@ export const server = new Server({
2426

2527
// Look up the tool by name in TOOL_MAP and call its handler
2628
server.setRequestHandler(CallToolRequestSchema, async (request) => {
27-
const tool = TOOL_MAP[request.params.name as keyof typeof TOOL_MAP]
28-
if (!tool) {
29-
throw new Error(`Tool ${request.params.name} not found`)
29+
const handler = TOOL_MAP[request.params.name as keyof typeof TOOL_MAP]
30+
if (!handler) {
31+
throw new Error(`Handler for tool "${request.params.name}" not found`)
3032
}
3133

32-
return await tool.handler(request.params.arguments)
34+
return await handler(request.params.arguments)
3335
})
3436

3537
// List all tools
@@ -40,6 +42,7 @@ server.setRequestHandler(
4042
getData.toolListEntry,
4143
getSchema.toolListEntry,
4244
listDataflows.toolListEntry,
45+
getDataflow.toolListEntry,
4346
]
4447
})
4548
)

src/tools/get-data/handler.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,11 @@ describe('getData', () => {
5858
isError: false,
5959
content: [{
6060
type: 'text',
61-
text: JSON.stringify([{ col_0: 1, col_1: 'Test' }])
62-
}]
61+
text: JSON.stringify([{ col_0: 1, col_1: 'Test' }], null, 2)
62+
}],
63+
structuredContent: {
64+
data: [{ col_0: 1, col_1: 'Test' }]
65+
}
6366
})
6467
})
6568

src/tools/get-data/handler.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { logger } from '@/logger'
66
import { textResponse } from '@/util/tool-response'
77
import { FileManager } from '@/tools/shared/file-manager'
88

9-
import { zodSchema } from './input-schema'
9+
import { zodInputSchema } from './schema'
1010

1111
export const handler = async (params?: Record<string, unknown>): Promise<CallToolResult> => {
12-
const validationResult = zodSchema.safeParse(params)
12+
const validationResult = zodInputSchema.safeParse(params)
1313

1414
if (!validationResult.success) {
1515
const error = fromError(validationResult.error)
@@ -41,5 +41,8 @@ export const handler = async (params?: Record<string, unknown>): Promise<CallToo
4141
db.close()
4242
}
4343

44-
return textResponse({ text: JSON.stringify(queryResult) })
44+
return textResponse({
45+
text: JSON.stringify(queryResult, null, 2),
46+
structuredContent: { data: queryResult }
47+
})
4548
}

src/tools/get-data/index.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { inputSchema } from './input-schema'
2-
import { handler } from './handler'
1+
import { inputSchema, outputSchema } from './schema'
2+
3+
export { handler } from './handler'
34

4-
export { inputSchema } from './input-schema'
55
export const name = 'get-data'
66
export const description = 'Get data from a Coupler.io data flow run. Make sure to first query a sample of 5 rows from `data` table, e.g. `SELECT * from data LIMIT 5`, and then run the `get-schema` tool, to better understand the structure. The `get-schema` tool will return the JSON-encoded schema of the `data` table. When visualizing the data, do not try to read any files or fetch any URLs, just generate a static page and use the data you get from the tools.'
77

@@ -10,16 +10,10 @@ const annotations = {
1010
idempotentHint: true,
1111
}
1212

13-
export const toolMapEntry = {
14-
name,
15-
description,
16-
inputSchema,
17-
handler,
18-
}
19-
2013
export const toolListEntry = {
2114
name,
2215
description,
2316
inputSchema,
17+
outputSchema,
2418
annotations,
2519
}

src/tools/get-data/input-schema.ts renamed to src/tools/get-data/schema.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { z } from 'zod'
22
import { zodToJsonSchema } from 'zod-to-json-schema'
33

4-
export const zodSchema = z.object({
4+
export const zodInputSchema = z.object({
55
dataflowId: z.string()
66
.min(1, 'dataflowId is required')
77
.regex(/^\S+$/, 'dataflowId must not contain whitespace')
@@ -16,4 +16,12 @@ export const zodSchema = z.object({
1616
.describe('The SQL query to run on the data flow sqlite file.'),
1717
}).strict()
1818

19-
export const inputSchema = zodToJsonSchema(zodSchema)
19+
export const inputSchema = zodToJsonSchema(zodInputSchema)
20+
21+
const zodOutputSchema = z.object({
22+
data: z.array(
23+
z.record(z.unknown())
24+
).describe('The data returned from the query.'),
25+
}).strict()
26+
27+
export const outputSchema = zodToJsonSchema(zodOutputSchema)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
import { handler } from './handler'
4+
5+
const createMockResponse = (responseFn: () => Promise<Response>): typeof fetch => {
6+
return responseFn
7+
}
8+
9+
const mockResponse = {
10+
id: 'gsheet_dataflow',
11+
name: 'GSheet data flow',
12+
last_successful_execution_id: '11'
13+
}
14+
15+
// Response mocks
16+
const mockGetDataflow = createMockResponse(
17+
async () => new Response(
18+
JSON.stringify(mockResponse)
19+
)
20+
)
21+
22+
const mockGetDataflowError = createMockResponse(
23+
async () => new Response('Error when getting dataflow', { status: 500 })
24+
)
25+
26+
const mockFetch = vi.spyOn(globalThis, 'fetch')
27+
28+
describe('get-dataflow', () => {
29+
beforeEach(async () => {
30+
mockFetch.mockReset()
31+
})
32+
33+
afterAll(async () => {
34+
mockFetch.mockRestore()
35+
})
36+
37+
it('returns data flow', async () => {
38+
mockFetch
39+
.mockImplementationOnce(mockGetDataflow)
40+
41+
const toolResult = await handler({ dataflowId: 'gsheet_dataflow' })
42+
43+
expect(toolResult).toEqual({
44+
isError: false,
45+
content: [{
46+
type: 'text',
47+
text: JSON.stringify(mockResponse, null, 2),
48+
}],
49+
structuredContent: {
50+
dataflow: mockResponse
51+
},
52+
})
53+
})
54+
55+
describe('with any error', () => {
56+
it('returns error message', async () => {
57+
mockFetch
58+
.mockImplementationOnce(mockGetDataflowError)
59+
60+
const toolResult = await handler({ dataflowId: 'gsheet_dataflow' })
61+
62+
expect(toolResult).toEqual({
63+
isError: true,
64+
content: [{
65+
type: 'text',
66+
text: 'Failed to get data flow gsheet_dataflow. Response status: 500',
67+
}]
68+
})
69+
})
70+
})
71+
})
72+
73+
describe('with invalid params', () => {
74+
it('returns errors on missing parameters', async () => {
75+
const toolResult = await handler()
76+
77+
expect(toolResult).toEqual({
78+
isError: true,
79+
content: [{
80+
type: 'text',
81+
text: 'Invalid parameters for get-dataflow tool. Validation error: Required',
82+
}]
83+
})
84+
})
85+
86+
it('returns error on missing dataflowId', async () => {
87+
const toolResult = await handler({})
88+
89+
expect(toolResult).toEqual({
90+
isError: true,
91+
content: [{
92+
type: 'text',
93+
text: 'Invalid parameters for get-dataflow tool. Validation error: Required at "dataflowId"',
94+
}]
95+
})
96+
})
97+
98+
it('returns error on invalid dataflowId or extraneous parameters', async () => {
99+
const toolResult = await handler({ dataflowId: 123, executionId: true })
100+
101+
expect(toolResult).toEqual({
102+
isError: true,
103+
content: [{
104+
type: 'text',
105+
text: "Invalid parameters for get-dataflow tool. Validation error: Expected string, received number at \"dataflowId\"; Unrecognized key(s) in object: 'executionId'"
106+
}]
107+
})
108+
})
109+
})

src/tools/get-dataflow/handler.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
2+
3+
import { zodInputSchema } from './schema'
4+
5+
import { logger } from '@/logger'
6+
import { textResponse } from '@/util/tool-response'
7+
import { COUPLER_ACCESS_TOKEN } from '@/env'
8+
import { CouplerioClient } from '@/lib/couplerio-client'
9+
import { fromError } from 'zod-validation-error'
10+
11+
export const handler = async (params?: Record<string, unknown>): Promise<CallToolResult> => {
12+
const validationResult = zodInputSchema.safeParse(params)
13+
14+
if (!validationResult.success) {
15+
const error = fromError(validationResult.error)
16+
logger.error(`Invalid parameters for get-dataflow tool: ${error.toString()}`)
17+
18+
return textResponse({
19+
text: `Invalid parameters for get-dataflow tool. ${error.toString()}`,
20+
isError: true,
21+
})
22+
}
23+
24+
const coupler = new CouplerioClient({ auth: COUPLER_ACCESS_TOKEN })
25+
const response = await coupler.request('/dataflows/{dataflowId}{?type}', {
26+
expand: {
27+
dataflowId: validationResult.data.dataflowId,
28+
type: 'from_template'
29+
},
30+
request: {
31+
method: 'GET'
32+
}
33+
})
34+
35+
if (!response.ok) {
36+
logger.error(`Failed to get data flow ${validationResult.data.dataflowId}. Response status: ${response.status}`)
37+
return textResponse({
38+
isError: true,
39+
text: `Failed to get data flow ${validationResult.data.dataflowId}. Response status: ${response.status}`
40+
})
41+
}
42+
43+
const dataflow = await response.json()
44+
45+
return textResponse({ text: JSON.stringify(dataflow, null, 2), structuredContent: { dataflow } })
46+
}

src/tools/get-dataflow/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { inputSchema, outputSchema } from './schema'
2+
3+
export { handler } from './handler'
4+
5+
export const name = 'get-dataflow'
6+
export const description = 'Get a Coupler.io data flow by ID.'
7+
8+
const annotations = {
9+
title: 'Get a Coupler.io data flow by ID.'
10+
}
11+
12+
export const toolListEntry = {
13+
name,
14+
description,
15+
inputSchema,
16+
outputSchema,
17+
annotations,
18+
}

src/tools/get-dataflow/schema.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { z } from 'zod'
2+
import { zodToJsonSchema } from 'zod-to-json-schema'
3+
4+
export const zodInputSchema = z.object({
5+
dataflowId: z.string()
6+
.min(1, 'dataflowId is required.')
7+
.regex(/^\S+$/, 'dataflowId must not be empty.')
8+
.describe('The ID of the data flow with a successful run.')
9+
}).strict()
10+
11+
export const inputSchema = zodToJsonSchema(zodInputSchema)
12+
13+
const zodOutputSchema = z.object({
14+
dataflow: z.object({
15+
id: z.string().describe('The ID of the data flow.'),
16+
name: z.string().describe('The name of the data flow.'),
17+
last_successful_execution_id: z.string().describe('The ID of the last successful run (execution) of the data flow.'),
18+
schedule: z.string().describe('The schedule of the data flow. Crontab format.'),
19+
sources: z.array(z.object({
20+
id: z.string().describe('The ID of the source.'),
21+
name: z.string().describe('The name of the source.'),
22+
type: z.string().describe('The type of the source.'),
23+
params_configured: z.boolean().describe('Whether the source params are configured.'),
24+
enabled: z.boolean().describe('Whether the source is enabled.'),
25+
data_connections_count: z.number().int().nonnegative().describe('The number of data connections for the source.'),
26+
last_success_run_at: z.string().describe('The date and time of the last successful run of the source. ISO 8601 format.'),
27+
error_details: z.string().describe('The error details of the source.'),
28+
})).describe('The sources of the data flow.'),
29+
}).strict()
30+
})
31+
32+
export const outputSchema = zodToJsonSchema(zodOutputSchema)

0 commit comments

Comments
 (0)