@nuxtagentic/integrations 0.0.0-stage → 0.1.1

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.
Files changed (51) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +175 -2
  3. package/client.ts +124 -0
  4. package/module.ts +44 -0
  5. package/package.json +52 -3
  6. package/runtime/assets/logos/discord.svg +1 -0
  7. package/runtime/assets/logos/github.svg +1 -0
  8. package/runtime/assets/logos/gmail.svg +1 -0
  9. package/runtime/assets/logos/googleads.svg +1 -0
  10. package/runtime/assets/logos/googleanalytics.svg +1 -0
  11. package/runtime/assets/logos/googlecalendar.svg +1 -0
  12. package/runtime/assets/logos/googledrive.svg +1 -0
  13. package/runtime/assets/logos/googlesearchconsole.svg +1 -0
  14. package/runtime/assets/logos/hubspot.svg +1 -0
  15. package/runtime/assets/logos/intercom.svg +1 -0
  16. package/runtime/assets/logos/linear.svg +1 -0
  17. package/runtime/assets/logos/mcp.svg +1 -0
  18. package/runtime/assets/logos/meta.svg +1 -0
  19. package/runtime/assets/logos/notion.svg +1 -0
  20. package/runtime/assets/logos/reddit.svg +1 -0
  21. package/runtime/assets/logos/sentry.svg +1 -0
  22. package/runtime/assets/logos/shopify.svg +1 -0
  23. package/runtime/assets/logos/slack.svg +1 -0
  24. package/runtime/assets/logos/stripe.svg +1 -0
  25. package/runtime/assets/logos/tiktok-dark.svg +1 -0
  26. package/runtime/assets/logos/tiktok.svg +1 -0
  27. package/runtime/assets/logos/vercel.svg +1 -0
  28. package/runtime/assets/logos/x.svg +1 -0
  29. package/runtime/components/IntegrationCard.vue +82 -0
  30. package/runtime/components/IntegrationCatalog.vue +130 -0
  31. package/runtime/components/IntegrationConnectionPanel.vue +589 -0
  32. package/runtime/components/IntegrationLogo.vue +41 -0
  33. package/runtime/pages/integrations.vue +481 -0
  34. package/runtime/server/api/integrations/approvals/[id].post.ts +69 -0
  35. package/runtime/server/api/integrations/catalog/[provider]/action.post.ts +35 -0
  36. package/runtime/server/api/integrations/catalog/[provider]/connection.post.ts +242 -0
  37. package/runtime/server/api/integrations/catalog/[provider]/tools.get.ts +40 -0
  38. package/runtime/server/api/integrations/catalog/index.get.ts +48 -0
  39. package/runtime/server/api/integrations/catalog/tools.get.ts +27 -0
  40. package/runtime/server/plugins/connect-maintenance.ts +30 -0
  41. package/runtime/server/utils/approval-continuation.ts +37 -0
  42. package/runtime/server/utils/google-mcp.ts +296 -0
  43. package/runtime/server/utils/integration-adapters.ts +836 -0
  44. package/runtime/server/utils/integration-context.ts +510 -0
  45. package/runtime/server/utils/integration-crypto.ts +13 -0
  46. package/runtime/server/utils/request-body.ts +38 -0
  47. package/runtime/shared/agent.ts +14 -0
  48. package/runtime/shared/catalog.ts +566 -0
  49. package/runtime/shared/google-mcp.ts +8 -0
  50. package/runtime/workspace.ts +15 -0
  51. 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
+ }