@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/LICENSE +60 -0
- package/README.md +243 -0
- package/package.json +46 -0
- package/src/analytics.js +264 -0
- package/src/index.js +350 -0
- package/src/loader.js +496 -0
- package/src/local.js +91 -0
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
|
+
}
|