@adobe/llm-apps-runtime 0.2.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/src/index.js ADDED
@@ -0,0 +1,350 @@
1
+ /*
2
+ Copyright 2022 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+
7
+ Unless required by applicable law or agreed to in writing, software distributed under
8
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ OF ANY KIND, either express or implied. See the License for the specific language
10
+ governing permissions and limitations under the License.
11
+ */
12
+
13
+ /**
14
+ * @adobe/llm-apps-runtime
15
+ *
16
+ * Creates an Adobe I/O Runtime main() function for an Adobe LLM Apps server.
17
+ *
18
+ * Usage in consumer app entry.js (webpack entry):
19
+ *
20
+ * const { createMain } = require('@adobe/llm-apps-runtime')
21
+ *
22
+ * const moduleContext = require.context('./actions', true, /index\.js$/)
23
+ * const htmlContext = require.context('./actions', true, /widget\.html$/)
24
+ * let actionsConfig = {}
25
+ * try { actionsConfig = require('./actions.json') } catch (e) {}
26
+ *
27
+ * module.exports = createMain({ moduleContext, htmlContext, actionsConfig })
28
+ *
29
+ * Usage in Jest tests (fs-based, no webpack):
30
+ *
31
+ * const { createMain } = require('@adobe/llm-apps-runtime')
32
+ * const { main } = createMain({ actionsDir: path.join(__dirname, '..', 'actions') })
33
+ */
34
+
35
+ const { Core } = require('@adobe/aio-sdk')
36
+ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp')
37
+ const { WebStandardStreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/webStandardStreamableHttp')
38
+ const {
39
+ resolveActions,
40
+ registerAction,
41
+ // re-exported as public API
42
+ loadActionsFromContexts,
43
+ loadActionsFromFs,
44
+ loadActionsConfig
45
+ } = require('./loader.js')
46
+ const { createBuffer, readAnalyticsConfig } = require('./analytics.js')
47
+ const crypto = require('crypto')
48
+ const path = require('path')
49
+
50
+ if (!global.crypto) {
51
+ global.crypto = crypto
52
+ }
53
+
54
+ /**
55
+ * Create the Adobe I/O Runtime main() function for an Adobe LLM Apps server.
56
+ *
57
+ * @param {object} options
58
+ * Webpack path (from entry.js):
59
+ * moduleContext {object} webpack require.context for actions/..../index.js
60
+ * htmlContext {object} webpack require.context for actions/..../widget.html
61
+ * actionsConfig {object} parsed actions.json keyed by action name
62
+ * Fs path (Jest / plain Node):
63
+ * actionsDir {string} absolute path to the actions/ directory
64
+ * Common:
65
+ * serverName {string} MCP server name (default: 'adobe-llm-apps')
66
+ * serverVersion {string} MCP server version (default: '1.0.0')
67
+ *
68
+ * @returns {{ main: Function }}
69
+ */
70
+ function createMain (options = {}) {
71
+ const serverName = options.serverName || 'adobe-llm-apps'
72
+ const serverVersion = options.serverVersion || '1.0.0'
73
+
74
+ let logger = null
75
+
76
+ // Resolve actions once at startup (cold start). Logs fire here.
77
+ // Per-request server creation reuses this resolved list silently.
78
+ const resolvedActions = resolveActions(options)
79
+
80
+ function createMcpServer (analyticsConfig) {
81
+ const server = new McpServer(
82
+ { name: serverName, version: serverVersion },
83
+ { capabilities: { logging: {}, tools: {}, resources: {} } }
84
+ )
85
+
86
+ for (const { name, action, widgetHtml, config } of resolvedActions) {
87
+ registerAction(server, name, action, widgetHtml, config, analyticsConfig)
88
+ }
89
+
90
+ return server
91
+ }
92
+
93
+ function parseRequestBody (params) {
94
+ if (!params.__ow_body) return null
95
+
96
+ try {
97
+ if (typeof params.__ow_body === 'string') {
98
+ try {
99
+ const decoded = Buffer.from(params.__ow_body, 'base64').toString('utf8')
100
+ return JSON.parse(decoded)
101
+ } catch (e) {
102
+ return JSON.parse(params.__ow_body)
103
+ }
104
+ }
105
+ return params.__ow_body
106
+ } catch (error) {
107
+ logger?.error('Failed to parse request body:', error)
108
+ throw new Error(`Failed to parse request body: ${error.message}`)
109
+ }
110
+ }
111
+
112
+ function handleHealthCheck () {
113
+ return {
114
+ statusCode: 200,
115
+ headers: {
116
+ 'Access-Control-Allow-Origin': '*',
117
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, DELETE',
118
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization, x-api-key, mcp-session-id, Last-Event-ID',
119
+ 'Access-Control-Expose-Headers': 'Content-Type, mcp-session-id, Last-Event-ID',
120
+ 'Access-Control-Max-Age': '86400',
121
+ 'Content-Type': 'application/json'
122
+ },
123
+ body: JSON.stringify({
124
+ status: 'healthy',
125
+ server: serverName,
126
+ version: serverVersion,
127
+ description: 'Adobe I/O Runtime Adobe LLM Apps Server',
128
+ timestamp: new Date().toISOString(),
129
+ transport: 'StreamableHTTP',
130
+ sdk: '@modelcontextprotocol/sdk'
131
+ })
132
+ }
133
+ }
134
+
135
+ function handleOptionsRequest () {
136
+ return {
137
+ statusCode: 200,
138
+ headers: {
139
+ 'Access-Control-Allow-Origin': '*',
140
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, DELETE',
141
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization, x-api-key, mcp-session-id, Last-Event-ID',
142
+ 'Access-Control-Expose-Headers': 'Content-Type, mcp-session-id, Last-Event-ID',
143
+ 'Access-Control-Max-Age': '86400'
144
+ },
145
+ body: ''
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Best-effort flush of the per-invocation analytics buffer. Errors are
151
+ * swallowed so analytics failure can never break an MCP response. The
152
+ * underlying flush() also enforces a tight timeout and circuit breaker.
153
+ */
154
+ async function flushAnalyticsSafe (analyticsConfig) {
155
+ if (!analyticsConfig || !analyticsConfig.buffer) return
156
+ try {
157
+ const result = await analyticsConfig.buffer.flush(analyticsConfig)
158
+ if (result && result.ok) {
159
+ logger?.info(`Analytics flushed: sent=${result.sent} dropped=${result.dropped || 0}`)
160
+ } else if (result && !result.ok) {
161
+ logger?.warn(`Analytics flush did not succeed: ${result.skipped || result.error || result.status}`)
162
+ }
163
+ } catch (e) {
164
+ logger?.warn(`Analytics flush threw: ${e.message}`)
165
+ }
166
+ }
167
+
168
+ async function handleMcpRequest (params) {
169
+ // Per-request analytics config + buffer. If config is null
170
+ // (unconfigured deploy or no enabled actions) the wrapper short-circuits
171
+ // to a no-op so unconfigured deploys cost zero overhead.
172
+ const cfg = readAnalyticsConfig(params)
173
+ const analyticsConfig = cfg ? { ...cfg, buffer: createBuffer({ logger }) } : null
174
+
175
+ const server = createMcpServer(analyticsConfig)
176
+
177
+ try {
178
+ logger?.info('Creating fresh MCP server and transport')
179
+
180
+ const body = parseRequestBody(params)
181
+ logger?.info('Request method:', body?.method)
182
+
183
+ const url = `https://${params.__ow_headers?.host || 'localhost'}/mcp-server`
184
+ const request = new Request(url, {
185
+ method: 'POST',
186
+ headers: {
187
+ 'Content-Type': 'application/json',
188
+ ...params.__ow_headers
189
+ },
190
+ body: JSON.stringify(body)
191
+ })
192
+
193
+ const transport = new WebStandardStreamableHTTPServerTransport({
194
+ sessionIdGenerator: undefined,
195
+ enableJsonResponse: true
196
+ })
197
+
198
+ await server.connect(transport)
199
+
200
+ const response = await transport.handleRequest(request)
201
+
202
+ const responseBody = await response.text()
203
+ const responseHeaders = {}
204
+ response.headers.forEach((value, key) => {
205
+ responseHeaders[key] = value
206
+ })
207
+
208
+ logger?.info('MCP request processed by SDK')
209
+
210
+ await flushAnalyticsSafe(analyticsConfig)
211
+
212
+ return {
213
+ statusCode: response.status,
214
+ headers: {
215
+ 'Access-Control-Allow-Origin': '*',
216
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, DELETE',
217
+ 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization, x-api-key, mcp-session-id, Last-Event-ID',
218
+ 'Access-Control-Expose-Headers': 'Content-Type, mcp-session-id, Last-Event-ID',
219
+ ...responseHeaders
220
+ },
221
+ body: responseBody
222
+ }
223
+ } catch (error) {
224
+ logger?.error('Error in handleMcpRequest:', error)
225
+
226
+ try { server.close() } catch (e) { /* ignore cleanup errors */ }
227
+
228
+ // Flush whatever we managed to buffer before the failure so
229
+ // partial telemetry isn't lost on the error path.
230
+ await flushAnalyticsSafe(analyticsConfig)
231
+
232
+ return {
233
+ statusCode: 500,
234
+ headers: {
235
+ 'Access-Control-Allow-Origin': '*',
236
+ 'Content-Type': 'application/json'
237
+ },
238
+ body: JSON.stringify({
239
+ jsonrpc: '2.0',
240
+ error: { code: -32603, message: `Internal server error: ${error.message}` },
241
+ id: null
242
+ })
243
+ }
244
+ }
245
+ }
246
+
247
+ async function main (params) {
248
+ try {
249
+ console.log('=== Adobe LLM Apps Server ===')
250
+ console.log('Method:', params.__ow_method)
251
+
252
+ try {
253
+ logger = Core.Logger(serverName, { level: params.LOG_LEVEL || 'info' })
254
+ } catch (loggerError) {
255
+ console.error('Logger creation error:', loggerError)
256
+ return {
257
+ statusCode: 500,
258
+ headers: { 'Content-Type': 'application/json' },
259
+ body: JSON.stringify({ error: `Logger creation error: ${loggerError.message}` })
260
+ }
261
+ }
262
+
263
+ logger.info('Adobe LLM Apps Server started')
264
+ logger.info(`Request method: ${params.__ow_method}`)
265
+
266
+ const incomingHeaders = {}
267
+ if (params.__ow_headers) {
268
+ for (const key in params.__ow_headers) {
269
+ incomingHeaders[key.toLowerCase()] = params.__ow_headers[key]
270
+ }
271
+ }
272
+
273
+ switch (params.__ow_method?.toLowerCase()) {
274
+ case 'get':
275
+ if (incomingHeaders.accept && incomingHeaders.accept.includes('text/event-stream')) {
276
+ logger.info('SSE stream requested — not supported in serverless')
277
+ return {
278
+ statusCode: 200,
279
+ headers: {
280
+ 'Access-Control-Allow-Origin': '*',
281
+ 'Content-Type': 'text/event-stream',
282
+ 'Cache-Control': 'no-cache',
283
+ Connection: 'close'
284
+ },
285
+ body: 'event: error\ndata: {"error": "SSE not supported in serverless. Use HTTP transport."}\n\n'
286
+ }
287
+ }
288
+ logger.info('Health check request')
289
+ return handleHealthCheck()
290
+
291
+ case 'options':
292
+ logger.info('CORS preflight request')
293
+ return handleOptionsRequest()
294
+
295
+ case 'post':
296
+ logger.info('MCP protocol request - delegating to SDK')
297
+ return await handleMcpRequest(params)
298
+
299
+ default:
300
+ logger.warn(`Method not allowed: ${params.__ow_method}`)
301
+ return {
302
+ statusCode: 405,
303
+ headers: {
304
+ 'Access-Control-Allow-Origin': '*',
305
+ 'Content-Type': 'application/json'
306
+ },
307
+ body: JSON.stringify({
308
+ jsonrpc: '2.0',
309
+ error: {
310
+ code: -32000,
311
+ message: `Method '${params.__ow_method}' not allowed. Supported: GET, POST, OPTIONS`
312
+ },
313
+ id: null
314
+ })
315
+ }
316
+ }
317
+ } catch (error) {
318
+ if (logger) {
319
+ logger.error('Uncaught error in main:', error)
320
+ } else {
321
+ console.error('Uncaught error in main:', error)
322
+ }
323
+
324
+ return {
325
+ statusCode: 500,
326
+ headers: {
327
+ 'Access-Control-Allow-Origin': '*',
328
+ 'Content-Type': 'application/json'
329
+ },
330
+ body: JSON.stringify({
331
+ jsonrpc: '2.0',
332
+ error: { code: -32603, message: `Unhandled server error: ${error.message}` },
333
+ id: null
334
+ })
335
+ }
336
+ }
337
+ }
338
+
339
+ return { main }
340
+ }
341
+
342
+ const { createLocalServer } = require('./local')
343
+
344
+ module.exports = {
345
+ createMain,
346
+ createLocalServer,
347
+ loadActionsFromContexts,
348
+ loadActionsFromFs,
349
+ loadActionsConfig
350
+ }