@nuxtagentic/integrations 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +9 -0
- package/README.md +175 -2
- package/client.ts +124 -0
- package/module.ts +44 -0
- package/package.json +52 -3
- package/runtime/assets/logos/discord.svg +1 -0
- package/runtime/assets/logos/github.svg +1 -0
- package/runtime/assets/logos/gmail.svg +1 -0
- package/runtime/assets/logos/googleads.svg +1 -0
- package/runtime/assets/logos/googleanalytics.svg +1 -0
- package/runtime/assets/logos/googlecalendar.svg +1 -0
- package/runtime/assets/logos/googledrive.svg +1 -0
- package/runtime/assets/logos/googlesearchconsole.svg +1 -0
- package/runtime/assets/logos/hubspot.svg +1 -0
- package/runtime/assets/logos/intercom.svg +1 -0
- package/runtime/assets/logos/linear.svg +1 -0
- package/runtime/assets/logos/mcp.svg +1 -0
- package/runtime/assets/logos/meta.svg +1 -0
- package/runtime/assets/logos/notion.svg +1 -0
- package/runtime/assets/logos/reddit.svg +1 -0
- package/runtime/assets/logos/sentry.svg +1 -0
- package/runtime/assets/logos/shopify.svg +1 -0
- package/runtime/assets/logos/slack.svg +1 -0
- package/runtime/assets/logos/stripe.svg +1 -0
- package/runtime/assets/logos/tiktok-dark.svg +1 -0
- package/runtime/assets/logos/tiktok.svg +1 -0
- package/runtime/assets/logos/vercel.svg +1 -0
- package/runtime/assets/logos/x.svg +1 -0
- package/runtime/components/IntegrationCard.vue +82 -0
- package/runtime/components/IntegrationCatalog.vue +130 -0
- package/runtime/components/IntegrationConnectionPanel.vue +589 -0
- package/runtime/components/IntegrationLogo.vue +41 -0
- package/runtime/pages/integrations.vue +481 -0
- package/runtime/server/api/integrations/approvals/[id].post.ts +69 -0
- package/runtime/server/api/integrations/catalog/[provider]/action.post.ts +35 -0
- package/runtime/server/api/integrations/catalog/[provider]/connection.post.ts +242 -0
- package/runtime/server/api/integrations/catalog/[provider]/tools.get.ts +40 -0
- package/runtime/server/api/integrations/catalog/index.get.ts +48 -0
- package/runtime/server/api/integrations/catalog/tools.get.ts +27 -0
- package/runtime/server/plugins/connect-maintenance.ts +30 -0
- package/runtime/server/utils/approval-continuation.ts +37 -0
- package/runtime/server/utils/google-mcp.ts +296 -0
- package/runtime/server/utils/integration-adapters.ts +836 -0
- package/runtime/server/utils/integration-context.ts +510 -0
- package/runtime/server/utils/integration-crypto.ts +13 -0
- package/runtime/server/utils/request-body.ts +38 -0
- package/runtime/shared/agent.ts +14 -0
- package/runtime/shared/catalog.ts +566 -0
- package/runtime/shared/google-mcp.ts +8 -0
- package/runtime/workspace.ts +15 -0
- package/schema.ts +179 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import type { Connect, ConnectActor, McpCallResult } from '@cloudconfig/core/connect'
|
|
2
|
+
import { GOOGLE_MCP_SERVERS } from '../../shared/google-mcp'
|
|
3
|
+
|
|
4
|
+
type Input = Record<string, unknown>
|
|
5
|
+
|
|
6
|
+
export interface GoogleMcpAction {
|
|
7
|
+
server: string
|
|
8
|
+
tool: string
|
|
9
|
+
arguments: Input
|
|
10
|
+
response: 'events' | 'draft' | 'files' | 'file'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isGoogleMcpEnabled(env: Record<string, unknown> | undefined): boolean {
|
|
14
|
+
return String(env?.NUXT_GOOGLE_MCP_ENABLED ?? process.env.NUXT_GOOGLE_MCP_ENABLED) === 'true'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The compatibility actions that write through Google's MCP servers ask for approval. */
|
|
18
|
+
export function isGoogleMcpWriteAction(provider: string, action: string): boolean {
|
|
19
|
+
return (
|
|
20
|
+
(provider === 'gcal' && action === 'create_events') ||
|
|
21
|
+
(provider === 'gmail' && action === 'draft_replies') ||
|
|
22
|
+
(provider === 'gdrive' && action === 'create_files')
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isObject(value: unknown): value is Input {
|
|
27
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readObject(value: unknown): Input {
|
|
31
|
+
return isObject(value) ? value : {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasOnlyFields(input: Input, fields: readonly string[]): boolean {
|
|
35
|
+
return Object.keys(input).every((field) => fields.includes(field))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Preserve the existing action inputs. Requests Google's tools cannot faithfully
|
|
40
|
+
* represent stay on REST; an MCP failure never causes a second operation via REST.
|
|
41
|
+
*/
|
|
42
|
+
export function planGoogleMcpAction({
|
|
43
|
+
provider,
|
|
44
|
+
action,
|
|
45
|
+
input,
|
|
46
|
+
}: {
|
|
47
|
+
provider: string
|
|
48
|
+
action: string
|
|
49
|
+
input: Input
|
|
50
|
+
}): GoogleMcpAction | null {
|
|
51
|
+
switch (`${provider}_${action}`) {
|
|
52
|
+
case 'gcal_read_events':
|
|
53
|
+
return planCalendarRead(input)
|
|
54
|
+
case 'gcal_create_events':
|
|
55
|
+
return planCalendarCreation(input)
|
|
56
|
+
case 'gmail_draft_replies':
|
|
57
|
+
return planGmailDraft(input)
|
|
58
|
+
case 'gdrive_read_files':
|
|
59
|
+
return planDriveRead(input)
|
|
60
|
+
case 'gdrive_create_files':
|
|
61
|
+
return planDriveCreation(input)
|
|
62
|
+
default:
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function planCalendarRead(input: Input): GoogleMcpAction | null {
|
|
68
|
+
const query = readObject(input.query)
|
|
69
|
+
|
|
70
|
+
const fields = {
|
|
71
|
+
timeMin: 'startTime',
|
|
72
|
+
timeMax: 'endTime',
|
|
73
|
+
timeZone: 'timeZone',
|
|
74
|
+
q: 'fullText',
|
|
75
|
+
maxResults: 'pageSize',
|
|
76
|
+
pageToken: 'pageToken',
|
|
77
|
+
}
|
|
78
|
+
if (!hasOnlyFields(query, Object.keys(fields))) return null
|
|
79
|
+
const toolArguments: Input = {
|
|
80
|
+
calendarId: input.calendarId ?? 'primary',
|
|
81
|
+
pageSize: 250,
|
|
82
|
+
eventType: [
|
|
83
|
+
'DEFAULT',
|
|
84
|
+
'OUT_OF_OFFICE',
|
|
85
|
+
'FOCUS_TIME',
|
|
86
|
+
'FROM_GMAIL',
|
|
87
|
+
'WORKING_LOCATION',
|
|
88
|
+
'BIRTHDAY',
|
|
89
|
+
],
|
|
90
|
+
}
|
|
91
|
+
for (const [source, target] of Object.entries(fields)) {
|
|
92
|
+
if (query[source] !== undefined) toolArguments[target] = query[source]
|
|
93
|
+
}
|
|
94
|
+
const pageSize = Number(toolArguments.pageSize)
|
|
95
|
+
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 250) return null
|
|
96
|
+
toolArguments.pageSize = pageSize
|
|
97
|
+
return {
|
|
98
|
+
server: GOOGLE_MCP_SERVERS.gcal,
|
|
99
|
+
tool: 'list_events',
|
|
100
|
+
arguments: toolArguments,
|
|
101
|
+
response: 'events',
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function planCalendarCreation(input: Input): GoogleMcpAction | null {
|
|
106
|
+
const query = readObject(input.query)
|
|
107
|
+
const body = readObject(input.body)
|
|
108
|
+
|
|
109
|
+
// Complex event options and differing start/end time zones keep their REST semantics.
|
|
110
|
+
if (
|
|
111
|
+
!hasOnlyFields(query, []) ||
|
|
112
|
+
!hasOnlyFields(body, ['summary', 'start', 'end', 'description', 'location'])
|
|
113
|
+
) {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
const start = readObject(body.start)
|
|
117
|
+
const end = readObject(body.end)
|
|
118
|
+
if (
|
|
119
|
+
!hasOnlyFields(start, ['dateTime', 'timeZone']) ||
|
|
120
|
+
!hasOnlyFields(end, ['dateTime', 'timeZone'])
|
|
121
|
+
) {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
typeof body.summary !== 'string' ||
|
|
126
|
+
typeof start.dateTime !== 'string' ||
|
|
127
|
+
typeof end.dateTime !== 'string'
|
|
128
|
+
) {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
if (start.timeZone !== end.timeZone) return null
|
|
132
|
+
return {
|
|
133
|
+
server: GOOGLE_MCP_SERVERS.gcal,
|
|
134
|
+
tool: 'create_event',
|
|
135
|
+
arguments: {
|
|
136
|
+
summary: body.summary,
|
|
137
|
+
calendarId: input.calendarId ?? 'primary',
|
|
138
|
+
startTime: start.dateTime,
|
|
139
|
+
endTime: end.dateTime,
|
|
140
|
+
// Calendar's REST insert sends no notifications unless explicitly requested.
|
|
141
|
+
notificationLevel: 'NONE',
|
|
142
|
+
...(start.timeZone ? { timeZone: start.timeZone } : {}),
|
|
143
|
+
...(body.description !== undefined ? { description: body.description } : {}),
|
|
144
|
+
...(body.location !== undefined ? { location: body.location } : {}),
|
|
145
|
+
},
|
|
146
|
+
response: 'events',
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function planGmailDraft(input: Input): GoogleMcpAction | null {
|
|
151
|
+
const query = readObject(input.query)
|
|
152
|
+
const body = readObject(input.body)
|
|
153
|
+
|
|
154
|
+
if (!hasOnlyFields(query, []) || !hasOnlyFields(body, ['to', 'subject', 'text'])) return null
|
|
155
|
+
if (
|
|
156
|
+
typeof body.to !== 'string' ||
|
|
157
|
+
typeof body.subject !== 'string' ||
|
|
158
|
+
typeof body.text !== 'string'
|
|
159
|
+
)
|
|
160
|
+
return null
|
|
161
|
+
const recipients = body.to.split(',').map((recipient) => recipient.trim())
|
|
162
|
+
if (recipients.some((recipient) => !/^[^\s<>@,]+@[^\s<>@,]+\.[^\s<>@,]+$/.test(recipient)))
|
|
163
|
+
return null
|
|
164
|
+
if (/[\r\n]/.test(body.to) || /[\r\n]/.test(body.subject)) {
|
|
165
|
+
throw new Error('Email recipient and subject must not contain line breaks.')
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
server: GOOGLE_MCP_SERVERS.gmail,
|
|
169
|
+
tool: 'create_draft',
|
|
170
|
+
arguments: { to: recipients, subject: body.subject, body: body.text },
|
|
171
|
+
response: 'draft',
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function planDriveRead(input: Input): GoogleMcpAction | null {
|
|
176
|
+
const query = readObject(input.query)
|
|
177
|
+
|
|
178
|
+
if (!hasOnlyFields(query, ['q', 'pageSize', 'pageToken'])) return null
|
|
179
|
+
let searchQuery = ''
|
|
180
|
+
if (query.q !== undefined) {
|
|
181
|
+
if (typeof query.q !== 'string') return null
|
|
182
|
+
// Drive MCP calls the file name "title" and supports a smaller search grammar.
|
|
183
|
+
const match = query.q.match(
|
|
184
|
+
/^(name|fullText|mimeType|modifiedTime|createdTime|viewedByMeTime)\s*(contains|!=|<=|>=|=|<|>)\s*('(?:\\.|[^'\\])*')$/,
|
|
185
|
+
)
|
|
186
|
+
if (!match) return null
|
|
187
|
+
const [, field, operator, value] = match
|
|
188
|
+
searchQuery = `${field === 'name' ? 'title' : field} ${operator} ${value}`
|
|
189
|
+
}
|
|
190
|
+
const pageSize = Number(query.pageSize ?? 100)
|
|
191
|
+
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 1000) return null
|
|
192
|
+
return {
|
|
193
|
+
server: GOOGLE_MCP_SERVERS.gdrive,
|
|
194
|
+
tool: 'search_files',
|
|
195
|
+
arguments: {
|
|
196
|
+
query: searchQuery,
|
|
197
|
+
pageSize,
|
|
198
|
+
...(query.pageToken !== undefined ? { pageToken: query.pageToken } : {}),
|
|
199
|
+
},
|
|
200
|
+
response: 'files',
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function planDriveCreation(input: Input): GoogleMcpAction | null {
|
|
205
|
+
const query = readObject(input.query)
|
|
206
|
+
const body = readObject(input.body)
|
|
207
|
+
|
|
208
|
+
if (!hasOnlyFields(query, []) || !hasOnlyFields(body, ['name', 'mimeType', 'parents']))
|
|
209
|
+
return null
|
|
210
|
+
if (typeof body.name !== 'string' || typeof body.mimeType !== 'string') return null
|
|
211
|
+
const supportedTypes = ['document', 'spreadsheet', 'presentation', 'folder'].map(
|
|
212
|
+
(type) => `application/vnd.google-apps.${type}`,
|
|
213
|
+
)
|
|
214
|
+
if (!supportedTypes.includes(body.mimeType)) return null
|
|
215
|
+
const parents = body.parents ?? []
|
|
216
|
+
if (
|
|
217
|
+
!Array.isArray(parents) ||
|
|
218
|
+
parents.length > 1 ||
|
|
219
|
+
parents.some((parent) => typeof parent !== 'string')
|
|
220
|
+
)
|
|
221
|
+
return null
|
|
222
|
+
return {
|
|
223
|
+
server: GOOGLE_MCP_SERVERS.gdrive,
|
|
224
|
+
tool: 'create_file',
|
|
225
|
+
arguments: {
|
|
226
|
+
title: body.name,
|
|
227
|
+
contentMimeType: body.mimeType,
|
|
228
|
+
...(parents[0] ? { parentId: parents[0] } : {}),
|
|
229
|
+
},
|
|
230
|
+
response: 'file',
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function readMcpResponse(result: McpCallResult): Input {
|
|
234
|
+
if (result.isError) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Google MCP rejected the action: ${JSON.stringify(result.content).slice(0, 500)}`,
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
if (isObject(result.structuredContent)) return result.structuredContent
|
|
240
|
+
for (const content of result.content) {
|
|
241
|
+
if (!isObject(content) || content.type !== 'text' || typeof content.text !== 'string') continue
|
|
242
|
+
try {
|
|
243
|
+
const value: unknown = JSON.parse(content.text)
|
|
244
|
+
if (isObject(value)) return value
|
|
245
|
+
} catch {
|
|
246
|
+
// Other text blocks may be explanatory; require a structured result below.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
throw new Error('Google MCP returned no structured action result.')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function normalizeDriveFile(value: unknown): Input {
|
|
253
|
+
if (!isObject(value)) throw new Error('Google MCP returned an invalid Drive file.')
|
|
254
|
+
const { title, fileSize, viewUrl, parentId, ...file } = value
|
|
255
|
+
return {
|
|
256
|
+
...file,
|
|
257
|
+
...(title !== undefined ? { name: title } : {}),
|
|
258
|
+
...(fileSize !== undefined ? { size: fileSize } : {}),
|
|
259
|
+
...(viewUrl !== undefined ? { webViewLink: viewUrl } : {}),
|
|
260
|
+
...(parentId !== undefined ? { parents: [parentId] } : {}),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Execute through Connect so the same account ownership and token refresh rules apply. */
|
|
265
|
+
export async function executeGoogleMcpAction({
|
|
266
|
+
connect,
|
|
267
|
+
actor,
|
|
268
|
+
connectionId,
|
|
269
|
+
request,
|
|
270
|
+
signal,
|
|
271
|
+
}: {
|
|
272
|
+
connect: Pick<Connect, 'mcp'>
|
|
273
|
+
actor: ConnectActor
|
|
274
|
+
connectionId: number
|
|
275
|
+
request: GoogleMcpAction
|
|
276
|
+
signal?: AbortSignal
|
|
277
|
+
}): Promise<Input> {
|
|
278
|
+
const session = connect.mcp(actor, connectionId, {
|
|
279
|
+
server: request.server,
|
|
280
|
+
...(signal ? { signal } : {}),
|
|
281
|
+
})
|
|
282
|
+
const response = readMcpResponse(await session.callTool(request.tool, request.arguments, 60_000))
|
|
283
|
+
if (request.response === 'files') {
|
|
284
|
+
if (!Array.isArray(response.files))
|
|
285
|
+
throw new Error('Google MCP returned an invalid Drive file list.')
|
|
286
|
+
return { ...response, files: response.files.map(normalizeDriveFile) }
|
|
287
|
+
}
|
|
288
|
+
if (request.response === 'file') return normalizeDriveFile(response)
|
|
289
|
+
if (request.response === 'events' && request.tool === 'list_events') {
|
|
290
|
+
if (!Array.isArray(response.events))
|
|
291
|
+
throw new Error('Google MCP returned an invalid calendar event list.')
|
|
292
|
+
const { events, ...calendar } = response
|
|
293
|
+
return { ...calendar, items: events }
|
|
294
|
+
}
|
|
295
|
+
return response
|
|
296
|
+
}
|