@app-connect/core 1.7.17 → 1.7.19

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 (57) hide show
  1. package/connector/proxy/index.js +2 -1
  2. package/handlers/log.js +181 -10
  3. package/handlers/plugin.js +27 -0
  4. package/handlers/user.js +31 -2
  5. package/index.js +99 -22
  6. package/lib/authSession.js +21 -12
  7. package/lib/callLogComposer.js +1 -1
  8. package/lib/debugTracer.js +20 -2
  9. package/lib/util.js +21 -4
  10. package/mcp/README.md +392 -0
  11. package/mcp/mcpHandler.js +293 -82
  12. package/mcp/tools/checkAuthStatus.js +27 -34
  13. package/mcp/tools/createCallLog.js +13 -9
  14. package/mcp/tools/createContact.js +2 -6
  15. package/mcp/tools/doAuth.js +27 -157
  16. package/mcp/tools/findContactByName.js +6 -9
  17. package/mcp/tools/findContactByPhone.js +2 -6
  18. package/mcp/tools/getGoogleFilePicker.js +5 -9
  19. package/mcp/tools/getHelp.js +2 -3
  20. package/mcp/tools/getPublicConnectors.js +41 -28
  21. package/mcp/tools/index.js +11 -36
  22. package/mcp/tools/logout.js +5 -10
  23. package/mcp/tools/rcGetCallLogs.js +3 -20
  24. package/mcp/ui/App/App.tsx +361 -0
  25. package/mcp/ui/App/components/AuthInfoForm.tsx +113 -0
  26. package/mcp/ui/App/components/AuthSuccess.tsx +22 -0
  27. package/mcp/ui/App/components/ConnectorList.tsx +82 -0
  28. package/mcp/ui/App/components/DebugPanel.tsx +43 -0
  29. package/mcp/ui/App/components/OAuthConnect.tsx +270 -0
  30. package/mcp/ui/App/lib/callTool.ts +130 -0
  31. package/mcp/ui/App/lib/debugLog.ts +41 -0
  32. package/mcp/ui/App/lib/developerPortal.ts +111 -0
  33. package/mcp/ui/App/main.css +6 -0
  34. package/mcp/ui/App/root.tsx +13 -0
  35. package/mcp/ui/dist/index.html +53 -0
  36. package/mcp/ui/index.html +13 -0
  37. package/mcp/ui/package-lock.json +6356 -0
  38. package/mcp/ui/package.json +25 -0
  39. package/mcp/ui/tsconfig.json +26 -0
  40. package/mcp/ui/vite.config.ts +16 -0
  41. package/models/llmSessionModel.js +14 -0
  42. package/package.json +2 -2
  43. package/releaseNotes.json +13 -1
  44. package/test/handlers/plugin.test.js +287 -0
  45. package/test/lib/util.test.js +379 -1
  46. package/test/mcp/tools/createCallLog.test.js +3 -3
  47. package/test/mcp/tools/doAuth.test.js +40 -303
  48. package/test/mcp/tools/findContactByName.test.js +3 -3
  49. package/test/mcp/tools/findContactByPhone.test.js +3 -3
  50. package/test/mcp/tools/getGoogleFilePicker.test.js +7 -7
  51. package/test/mcp/tools/getPublicConnectors.test.js +49 -70
  52. package/test/mcp/tools/logout.test.js +2 -2
  53. package/mcp/SupportedPlatforms.md +0 -12
  54. package/mcp/tools/collectAuthInfo.js +0 -91
  55. package/mcp/tools/setConnector.js +0 -69
  56. package/test/mcp/tools/collectAuthInfo.test.js +0 -234
  57. package/test/mcp/tools/setConnector.test.js +0 -177
@@ -0,0 +1,270 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { Button } from '@openai/apps-sdk-ui/components/Button'
3
+ import { callTool, getServerUrl, updateModelContext } from '../lib/callTool'
4
+ import { dbg } from '../lib/debugLog'
5
+
6
+ type AuthStatus = 'ready' | 'creatingSession' | 'polling' | 'error'
7
+
8
+ interface OAuthConnectProps {
9
+ connectorManifest: any
10
+ connectorName: string
11
+ connectorDisplayName: string
12
+ hostname: string
13
+ openaiSessionId?: string | null
14
+ rcExtensionId?: string | null
15
+ onSuccess: (data: { jwtToken: string; userInfo?: any }) => void
16
+ onError: (error: string) => void
17
+ onBack: () => void
18
+ }
19
+
20
+ /**
21
+ * Compose the OAuth authorization URL entirely client-side.
22
+ * The widget already has the full manifest and serverUrl — no server round-trip needed.
23
+ */
24
+ function buildAuthUri(platform: any, sessionId: string, hostname: string, serverUrl: string): string {
25
+ const oauth = platform.auth.oauth
26
+ let stateParam = `sessionId=${sessionId}&platform=${platform.name}&hostname=${hostname}`
27
+ if (oauth.customState) stateParam += `&${oauth.customState}`
28
+
29
+ return `${oauth.authUrl}?` +
30
+ `response_type=code` +
31
+ `&client_id=${oauth.clientId}` +
32
+ `${oauth.scope ? `&${oauth.scope}` : ''}` +
33
+ `&state=${encodeURIComponent(stateParam)}` +
34
+ `&redirect_uri=${serverUrl}/oauth-callback`
35
+ }
36
+
37
+ const POLL_INTERVAL_MS = 5_000;
38
+ const MAX_POLL_ATTEMPTS = 100; // ~5 minutes
39
+
40
+ export function OAuthConnect({
41
+ connectorManifest,
42
+ connectorName,
43
+ connectorDisplayName,
44
+ hostname,
45
+ openaiSessionId,
46
+ rcExtensionId,
47
+ onSuccess,
48
+ onError,
49
+ onBack,
50
+ }: OAuthConnectProps) {
51
+ const [status, setStatus] = useState<AuthStatus>('ready')
52
+ const [authUri, setAuthUri] = useState<string | null>(null)
53
+ const [errorMsg, setErrorMsg] = useState<string | null>(null)
54
+ const [sessionReady, setSessionReady] = useState(false)
55
+
56
+ const sessionIdRef = useRef<string | null>(null)
57
+ const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
58
+ const pollCountRef = useRef(0)
59
+ const unmountedRef = useRef(false)
60
+
61
+ // Cleanup on unmount
62
+ useEffect(() => {
63
+ return () => {
64
+ unmountedRef.current = true
65
+ if (pollTimerRef.current) clearInterval(pollTimerRef.current)
66
+ }
67
+ }, [])
68
+
69
+ // Build authUri client-side immediately — no server round-trip needed.
70
+ // Simultaneously call doAuth in the background to create the DB session.
71
+ useEffect(() => {
72
+ let cancelled = false
73
+ const platform = connectorManifest?.platforms?.[connectorName]
74
+
75
+ if (!platform) {
76
+ setStatus('error')
77
+ setErrorMsg('Invalid connector configuration')
78
+ return
79
+ }
80
+
81
+ // Use openaiSessionId as the primary session key so the OAuth callback
82
+ // can be correlated with the ChatGPT conversation. Fall back to a random
83
+ // UUID when the widget is loaded outside ChatGPT.
84
+ const sessionId = openaiSessionId ?? crypto.randomUUID()
85
+ sessionIdRef.current = sessionId
86
+ const serverUrl = getServerUrl() ?? ''
87
+ const uri = buildAuthUri(platform, sessionId, hostname, serverUrl)
88
+ setAuthUri(uri)
89
+ dbg.info('authUri composed client-side, sessionId:', sessionId)
90
+
91
+ // Create the server-side session in background (fast DB write only)
92
+ callTool('doAuth', { connectorName, hostname, sessionId }).then((result) => {
93
+ if (cancelled || unmountedRef.current) return
94
+ if (!result?.success) {
95
+ dbg.warn('doAuth session creation failed:', result?.error)
96
+ setStatus('error')
97
+ setErrorMsg(result?.error || 'Failed to prepare authentication session')
98
+ return
99
+ }
100
+ dbg.info('doAuth session created successfully')
101
+ setSessionReady(true)
102
+ }).catch((err: any) => {
103
+ if (cancelled || unmountedRef.current) return
104
+ dbg.error('doAuth error:', err.message)
105
+ setStatus('error')
106
+ setErrorMsg(err.message || 'Failed to prepare authentication session')
107
+ })
108
+
109
+ return () => { cancelled = true }
110
+ }, [connectorManifest, connectorName, hostname])
111
+
112
+ const startPolling = () => {
113
+ setStatus('polling')
114
+ pollCountRef.current = 0
115
+
116
+ pollTimerRef.current = setInterval(async () => {
117
+ if (unmountedRef.current) {
118
+ if (pollTimerRef.current) clearInterval(pollTimerRef.current)
119
+ return
120
+ }
121
+
122
+ pollCountRef.current += 1
123
+ if (pollCountRef.current > MAX_POLL_ATTEMPTS) {
124
+ if (pollTimerRef.current) clearInterval(pollTimerRef.current)
125
+ setStatus('error')
126
+ setErrorMsg('Authorization timed out. Please try again.')
127
+ return
128
+ }
129
+
130
+ try {
131
+ const res = await callTool('checkAuthStatus', {
132
+ sessionId: sessionIdRef.current,
133
+ ...(rcExtensionId ? { rcExtensionId } : {}),
134
+ })
135
+ const sc = res?.data ?? null
136
+
137
+ if (unmountedRef.current) return
138
+
139
+ const dataStatus = sc?.status
140
+ if (dataStatus === 'completed' && sc?.jwtToken) {
141
+ if (pollTimerRef.current) clearInterval(pollTimerRef.current)
142
+
143
+ // Push the JWT into ChatGPT's model context so it can be used
144
+ // in future tool calls. updateModelContext tries ui/update-model-context
145
+ // first (invisible to user, visible to model), then falls back to ui/message.
146
+ const userLabel = sc.userInfo?.name ? ` as ${sc.userInfo.name}` : ''
147
+ await updateModelContext(
148
+ `CRM authentication with ${connectorDisplayName} completed${userLabel}. ` +
149
+ `Platform: ${connectorName}. ` +
150
+ `jwtToken: ${sc.jwtToken}. ` +
151
+ `IMPORTANT: Store this jwtToken and pass it as a parameter to all future CRM tool calls.`
152
+ )
153
+ dbg.info('updateModelContext sent with jwtToken')
154
+
155
+ onSuccess({
156
+ jwtToken: sc.jwtToken,
157
+ userInfo: sc.userInfo,
158
+ })
159
+ } else if (dataStatus === 'failed') {
160
+ if (pollTimerRef.current) clearInterval(pollTimerRef.current)
161
+ setStatus('error')
162
+ setErrorMsg(sc?.errorMessage || 'Authorization failed')
163
+ } else if (dataStatus === 'expired') {
164
+ if (pollTimerRef.current) clearInterval(pollTimerRef.current)
165
+ setStatus('error')
166
+ setErrorMsg('Authorization session expired. Please try again.')
167
+ }
168
+ // 'pending' -> keep polling
169
+ } catch {
170
+ // Transient errors during polling are acceptable; keep trying
171
+ }
172
+ }, POLL_INTERVAL_MS)
173
+ }
174
+
175
+ const handleAuthorizeClick = () => {
176
+ if (authUri && sessionReady) {
177
+ window.open(authUri, '_blank', 'noopener,noreferrer')
178
+ startPolling()
179
+ }
180
+ }
181
+
182
+ const handleRetry = () => {
183
+ setStatus('error')
184
+ setErrorMsg(null)
185
+ onError('retry')
186
+ }
187
+
188
+ // Error state
189
+ if (status === 'error') {
190
+ return (
191
+ <div className="w-full max-w-md">
192
+ <div className="mb-4">
193
+ <button
194
+ type="button"
195
+ onClick={onBack}
196
+ className="text-sm text-secondary hover:text-primary transition-colors mb-2 cursor-pointer"
197
+ >
198
+ &larr; Back
199
+ </button>
200
+ <h2 className="heading-lg">Authentication Error</h2>
201
+ </div>
202
+ <div className="rounded-lg border border-red-200 bg-red-50 p-4 mb-4">
203
+ <p className="text-red-700 text-sm">{errorMsg}</p>
204
+ </div>
205
+ <Button color="primary" size="sm" onClick={handleRetry}>
206
+ Try Again
207
+ </Button>
208
+ </div>
209
+ )
210
+ }
211
+
212
+ // Ready to authorize or polling
213
+ return (
214
+ <div className="w-full max-w-md">
215
+ <div className="mb-4">
216
+ <button
217
+ type="button"
218
+ onClick={onBack}
219
+ className="text-sm text-secondary hover:text-primary transition-colors mb-2 cursor-pointer"
220
+ >
221
+ &larr; Back
222
+ </button>
223
+ <h2 className="heading-lg">Authorize {connectorDisplayName}</h2>
224
+ </div>
225
+
226
+ <div className="rounded-xl border border-default bg-surface p-4 space-y-4">
227
+ {status === 'ready' && (
228
+ <>
229
+ <p className="text-sm text-secondary">
230
+ Click the button below to open {connectorDisplayName}'s authorization
231
+ page. After you approve access, return here to continue.
232
+ </p>
233
+ <Button
234
+ color="primary"
235
+ size="md"
236
+ onClick={handleAuthorizeClick}
237
+ disabled={!sessionReady}
238
+ >
239
+ {sessionReady
240
+ ? `Authorize in ${connectorDisplayName}`
241
+ : 'Preparing…'}
242
+ </Button>
243
+ </>
244
+ )}
245
+
246
+ {status === 'polling' && (
247
+ <div className="text-center space-y-3">
248
+ <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mx-auto" />
249
+ <p className="text-sm text-secondary">
250
+ Waiting for you to authorize in {connectorDisplayName}...
251
+ </p>
252
+ <p className="text-xs text-secondary opacity-60">
253
+ Complete the authorization in the opened tab, then come back here.
254
+ </p>
255
+ {authUri && (
256
+ <a
257
+ href={authUri}
258
+ target="_blank"
259
+ rel="noopener noreferrer"
260
+ className="text-xs text-primary hover:underline"
261
+ >
262
+ Open authorization page again
263
+ </a>
264
+ )}
265
+ </div>
266
+ )}
267
+ </div>
268
+ </div>
269
+ )
270
+ }
@@ -0,0 +1,130 @@
1
+ import { dbg } from './debugLog';
2
+
3
+ /**
4
+ * Server URL for direct HTTP tool calls.
5
+ * Set from structuredContent.serverUrl when the initial tool output arrives.
6
+ */
7
+ let _serverUrl: string | null = null;
8
+
9
+ /**
10
+ * Send a JSON-RPC 2.0 request to the host (ChatGPT) and await the response.
11
+ * Used for MCP Apps bridge calls that require a round-trip (e.g. ui/update-model-context).
12
+ */
13
+ function rpcRequest(method: string, params: unknown, timeoutMs = 10_000): Promise<unknown> {
14
+ return new Promise((resolve, reject) => {
15
+ const id = `${method}-${Date.now()}-${Math.random().toString(36).slice(2)}`
16
+
17
+ const timer = setTimeout(() => {
18
+ window.removeEventListener('message', onMessage)
19
+ reject(new Error(`rpcRequest "${method}" timed out after ${timeoutMs}ms`))
20
+ }, timeoutMs)
21
+
22
+ function onMessage(event: MessageEvent) {
23
+ if (event.source !== window.parent) return
24
+ const msg = event.data
25
+ if (!msg || msg.jsonrpc !== '2.0' || msg.id !== id) return
26
+ clearTimeout(timer)
27
+ window.removeEventListener('message', onMessage)
28
+ if (msg.error) reject(new Error(msg.error.message ?? JSON.stringify(msg.error)))
29
+ else resolve(msg.result)
30
+ }
31
+
32
+ window.addEventListener('message', onMessage, { passive: true })
33
+ window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*')
34
+ })
35
+ }
36
+
37
+ /**
38
+ * Inject text into the model's context without showing it as a visible chat message.
39
+ * Uses the MCP Apps bridge ui/update-model-context (preferred), with ui/message as fallback.
40
+ */
41
+ export async function updateModelContext(text: string): Promise<void> {
42
+ const content = [{ type: 'text', text }]
43
+
44
+ // Primary: MCP Apps standard — invisible to user, visible to model
45
+ try {
46
+ await rpcRequest('ui/update-model-context', { content })
47
+ dbg.info('updateModelContext: ui/update-model-context succeeded')
48
+ return
49
+ } catch (err: any) {
50
+ dbg.warn('updateModelContext: ui/update-model-context failed, trying ui/message:', err.message)
51
+ }
52
+
53
+ // Fallback: visible user message (ui/message notification — fire and forget)
54
+ try {
55
+ window.parent.postMessage(
56
+ { jsonrpc: '2.0', method: 'ui/message', params: { role: 'user', content } },
57
+ '*',
58
+ )
59
+ dbg.info('updateModelContext: ui/message sent')
60
+ return
61
+ } catch (err: any) {
62
+ dbg.warn('updateModelContext: ui/message failed, trying window.openai.sendMessage:', err.message)
63
+ }
64
+
65
+ // Last resort: legacy ChatGPT Apps SDK
66
+ const openai = (window as any).openai
67
+ if (typeof openai?.sendMessage === 'function') {
68
+ openai.sendMessage(text)
69
+ dbg.info('updateModelContext: window.openai.sendMessage used')
70
+ } else {
71
+ dbg.error('updateModelContext: no mechanism available to update model context')
72
+ }
73
+ }
74
+
75
+ export function setServerUrl(url: string) {
76
+ _serverUrl = url.replace(/\/+$/, '');
77
+ dbg.info('serverUrl set to:', _serverUrl);
78
+ }
79
+
80
+ export function getServerUrl(): string | null {
81
+ return _serverUrl;
82
+ }
83
+
84
+ /**
85
+ * Call a widget-accessible tool on the server via direct fetch to /mcp/widget-tool-call.
86
+ *
87
+ * NOTE: window.openai.callTool() is intentionally NOT used here.
88
+ * It routes through ChatGPT's LLM which drops widget-provided args when the MCP
89
+ * tool has no Zod inputSchema registered — args arrive as undefined on the server.
90
+ * Direct fetch correctly forwards all args.
91
+ */
92
+ export async function callTool(
93
+ toolName: string,
94
+ args: Record<string, unknown> = {},
95
+ ): Promise<any> {
96
+ dbg.info(`callTool("${toolName}", ${JSON.stringify(args)})`);
97
+ dbg.info('_serverUrl:', _serverUrl);
98
+
99
+ if (!_serverUrl) {
100
+ const msg = 'serverUrl not set — ensure APP_SERVER env var is set and server restarted';
101
+ dbg.error(msg);
102
+ throw new Error(msg);
103
+ }
104
+
105
+ const endpoint = `${_serverUrl}/mcp/widget-tool-call`;
106
+ const body = JSON.stringify({ tool: toolName, toolArgs: args });
107
+ dbg.info('fetch POST', endpoint, 'body:', body);
108
+
109
+ try {
110
+ const res = await fetch(endpoint, {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body,
114
+ });
115
+
116
+ const text = await res.text();
117
+ dbg.info('fetch response:', res.status, text.slice(0, 300));
118
+
119
+ if (!res.ok) {
120
+ let parsed: any = null;
121
+ try { parsed = JSON.parse(text); } catch { /* ignore */ }
122
+ throw new Error(parsed?.error || `HTTP ${res.status}: ${text}`);
123
+ }
124
+
125
+ return JSON.parse(text);
126
+ } catch (err: any) {
127
+ dbg.error('fetch threw:', err.message);
128
+ throw err;
129
+ }
130
+ }
@@ -0,0 +1,41 @@
1
+ export interface DebugEntry {
2
+ time: string
3
+ level: 'info' | 'warn' | 'error'
4
+ msg: string
5
+ }
6
+
7
+ const MAX_ENTRIES = 50;
8
+ const entries: DebugEntry[] = [];
9
+ const listeners = new Set<() => void>();
10
+
11
+ function ts() {
12
+ return new Date().toISOString().slice(11, 23); // HH:MM:SS.mmm
13
+ }
14
+
15
+ function push(level: DebugEntry['level'], parts: unknown[]) {
16
+ const msg = parts
17
+ .map((p) => (typeof p === 'object' ? JSON.stringify(p) : String(p)))
18
+ .join(' ');
19
+ entries.push({ time: ts(), level, msg });
20
+ if (entries.length > MAX_ENTRIES) entries.shift();
21
+ // Mirror to real console
22
+ if (level === 'error') console.error('[debug]', msg);
23
+ else if (level === 'warn') console.warn('[debug]', msg);
24
+ else console.log('[debug]', msg);
25
+ listeners.forEach((fn) => fn());
26
+ }
27
+
28
+ export const dbg = {
29
+ info: (...args: unknown[]) => push('info', args),
30
+ warn: (...args: unknown[]) => push('warn', args),
31
+ error: (...args: unknown[]) => push('error', args),
32
+ };
33
+
34
+ export function getEntries(): DebugEntry[] {
35
+ return [...entries];
36
+ }
37
+
38
+ export function subscribe(fn: () => void): () => void {
39
+ listeners.add(fn);
40
+ return () => listeners.delete(fn);
41
+ }
@@ -0,0 +1,111 @@
1
+ import { dbg } from './debugLog'
2
+
3
+ const PORTAL_BASE = 'https://appconnect.labs.ringcentral.com/public-api'
4
+
5
+ export const SUPPORTED_PLATFORMS = ['clio']
6
+
7
+ export interface Connector {
8
+ id: string
9
+ name: string
10
+ displayName: string
11
+ description?: string
12
+ status?: 'public' | 'private'
13
+ }
14
+
15
+ /**
16
+ * Fetch the list of available connectors directly from the developer portal.
17
+ * Merges public connectors with private ones (if rcAccountId is provided),
18
+ * then filters to SUPPORTED_PLATFORMS.
19
+ */
20
+ export async function fetchConnectors(rcAccountId?: string | null): Promise<Connector[]> {
21
+ dbg.info('fetchConnectors: rcAccountId=', rcAccountId ?? '(none)')
22
+
23
+ const results: Connector[] = []
24
+
25
+ // Public connectors
26
+ const publicUrl = `${PORTAL_BASE}/connectors`
27
+ dbg.info('fetchConnectors: GET', publicUrl)
28
+ try {
29
+ const resp = await fetch(publicUrl)
30
+ dbg.info('fetchConnectors: public response status=', resp.status, 'ok=', resp.ok)
31
+ if (!resp.ok) {
32
+ const body = await resp.text().catch(() => '(unreadable)')
33
+ throw new Error(`HTTP ${resp.status}: ${body.slice(0, 200)}`)
34
+ }
35
+ const data = await resp.json()
36
+ dbg.info('fetchConnectors: public raw keys=', Object.keys(data).join(', '))
37
+ const list: any[] = data?.connectors ?? data ?? []
38
+ results.push(...list)
39
+ dbg.info('fetchConnectors: public count=', list.length)
40
+ } catch (err: any) {
41
+ dbg.error('fetchConnectors: failed to fetch public connectors:', err.message, '| name=', err.name, '| url=', publicUrl)
42
+ throw new Error(`Failed to load connectors: ${err.message}`)
43
+ }
44
+
45
+ // Private connectors (only if account ID is available)
46
+ if (rcAccountId) {
47
+ const privateUrl = `${PORTAL_BASE}/connectors/internal?accountId=${encodeURIComponent(rcAccountId)}`
48
+ dbg.info('fetchConnectors: GET', privateUrl)
49
+ try {
50
+ const resp = await fetch(privateUrl)
51
+ dbg.info('fetchConnectors: private response status=', resp.status, 'ok=', resp.ok)
52
+ if (resp.ok) {
53
+ const data = await resp.json()
54
+ dbg.info('fetchConnectors: private raw keys=', Object.keys(data).join(', '))
55
+ const list: any[] = data?.privateConnectors ?? data ?? []
56
+ results.push(...list)
57
+ dbg.info('fetchConnectors: private count=', list.length)
58
+ } else {
59
+ const body = await resp.text().catch(() => '(unreadable)')
60
+ dbg.warn('fetchConnectors: private non-ok response:', resp.status, body.slice(0, 200))
61
+ }
62
+ } catch (err: any) {
63
+ dbg.warn('fetchConnectors: failed to fetch private connectors (non-fatal):', err.message, '| name=', err.name)
64
+ }
65
+ }
66
+
67
+ // Filter to supported platforms and normalise shape
68
+ const supported = results
69
+ .filter((c) => SUPPORTED_PLATFORMS.includes(c.name))
70
+ .map((c): Connector => ({
71
+ id: c.id,
72
+ name: c.name,
73
+ displayName: c.displayName,
74
+ description: c.description || `Connect to ${c.displayName}`,
75
+ status: (c.status as 'public' | 'private') ?? 'public',
76
+ }))
77
+
78
+ dbg.info('fetchConnectors: supported=', supported.map((c) => c.name).join(', '))
79
+ return supported
80
+ }
81
+
82
+ /**
83
+ * Fetch the manifest for a specific connector directly from the developer portal.
84
+ */
85
+ export async function fetchManifest(
86
+ connectorId: string,
87
+ isPrivate: boolean,
88
+ rcAccountId?: string | null,
89
+ ): Promise<any> {
90
+ dbg.info('fetchManifest: connectorId=', connectorId, 'isPrivate=', isPrivate)
91
+
92
+ const url = isPrivate && rcAccountId
93
+ ? `${PORTAL_BASE}/connectors/${connectorId}/manifest?type=internal&accountId=${encodeURIComponent(rcAccountId)}`
94
+ : `${PORTAL_BASE}/connectors/${connectorId}/manifest`
95
+
96
+ dbg.info('fetchManifest: GET', url)
97
+ try {
98
+ const resp = await fetch(url)
99
+ dbg.info('fetchManifest: response status=', resp.status, 'ok=', resp.ok)
100
+ if (!resp.ok) {
101
+ const body = await resp.text().catch(() => '(unreadable)')
102
+ throw new Error(`HTTP ${resp.status}: ${body.slice(0, 200)}`)
103
+ }
104
+ const manifest = await resp.json()
105
+ dbg.info('fetchManifest: loaded platforms=', Object.keys(manifest?.platforms ?? {}).join(', '))
106
+ return manifest
107
+ } catch (err: any) {
108
+ dbg.error('fetchManifest: failed:', err.message, '| name=', err.name, '| url=', url)
109
+ throw err
110
+ }
111
+ }
@@ -0,0 +1,6 @@
1
+ @import "tailwindcss";
2
+ @import "@openai/apps-sdk-ui/css";
3
+ /* Required for Tailwind to find class references in Apps SDK UI components. */
4
+ @source "../../../node_modules/@openai/apps-sdk-ui";
5
+
6
+ /* The rest of your application CSS */
@@ -0,0 +1,13 @@
1
+ // Must be imported first to ensure Tailwind layers and style foundations are defined before any potential component styles
2
+ import "./main.css"
3
+
4
+ import { StrictMode } from "react"
5
+ import { createRoot } from "react-dom/client"
6
+ import { App } from "./App"
7
+
8
+ createRoot(document.getElementById("root")!).render(
9
+ <StrictMode>
10
+ <App />
11
+ </StrictMode>,
12
+ )
13
+