@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/loader.js
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
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
|
+
* Action Loader for @adobe/llm-apps-runtime.
|
|
15
|
+
*
|
|
16
|
+
* Two loading paths:
|
|
17
|
+
*
|
|
18
|
+
* loadActionsFromContexts(server, { moduleContext, htmlContext, actionsConfig })
|
|
19
|
+
* Used at runtime when webpack has already resolved the action modules and widget
|
|
20
|
+
* HTML files into context objects. Called from the consumer app's entry.js.
|
|
21
|
+
*
|
|
22
|
+
* loadActionsFromFs(server, actionsDir, actionsConfig)
|
|
23
|
+
* Used in Jest tests and any environment without webpack (plain Node).
|
|
24
|
+
* Reads modules and HTML files directly from the filesystem.
|
|
25
|
+
*
|
|
26
|
+
* Registration is driven by actionsConfig (the parsed actions.json) in both paths.
|
|
27
|
+
* When actionsConfig is empty, both paths fall back to filesystem discovery and print
|
|
28
|
+
* a banner reminding the developer to download actions.json from the llm-apps UI.
|
|
29
|
+
*
|
|
30
|
+
* Widget resolution priority (same in both paths):
|
|
31
|
+
* 1. widget.html file in the action directory
|
|
32
|
+
* 2. EDS config in actionsConfig (auto-generates aem-embed template)
|
|
33
|
+
* 3. Tool-only (no widget)
|
|
34
|
+
*
|
|
35
|
+
* Handler export shape (only shape accepted):
|
|
36
|
+
* module.exports = async (args) => ({ content, structuredContent? })
|
|
37
|
+
* ESM default-function exports are also accepted.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const fs = require('fs')
|
|
41
|
+
const path = require('path')
|
|
42
|
+
const { z } = require('zod')
|
|
43
|
+
const { safeSize } = require('./analytics.js')
|
|
44
|
+
|
|
45
|
+
const RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app'
|
|
46
|
+
|
|
47
|
+
const MISSING_CONFIG_BANNER = [
|
|
48
|
+
'',
|
|
49
|
+
' ┌─────────────────────────────────────────────────────────────────────┐',
|
|
50
|
+
' │ No actions.json found. │',
|
|
51
|
+
' │ Handlers will be registered with empty metadata. │',
|
|
52
|
+
' │ Download actions.json from the llm-apps UI to mirror production. │',
|
|
53
|
+
' └─────────────────────────────────────────────────────────────────────┘',
|
|
54
|
+
''
|
|
55
|
+
].join('\n')
|
|
56
|
+
|
|
57
|
+
function normalizeAction (mod) {
|
|
58
|
+
if (typeof mod === 'function') return { handler: mod }
|
|
59
|
+
if (mod && typeof mod.default === 'function') return { handler: mod.default }
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateAction (mod, source) {
|
|
64
|
+
if (normalizeAction(mod)) return true
|
|
65
|
+
console.warn(`Skipping ${source}: expected module.exports to be an async function`)
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createDefaultHandler () {
|
|
70
|
+
return async () => ({
|
|
71
|
+
content: [{ type: 'text', text: '' }],
|
|
72
|
+
structuredContent: {}
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function jsonSchemaPropertyToZod (prop, isRequired) {
|
|
77
|
+
let zodType
|
|
78
|
+
if (prop.enum) {
|
|
79
|
+
zodType = z.enum(prop.enum)
|
|
80
|
+
} else {
|
|
81
|
+
switch (prop.type) {
|
|
82
|
+
case 'string':
|
|
83
|
+
zodType = z.string()
|
|
84
|
+
break
|
|
85
|
+
case 'number':
|
|
86
|
+
zodType = z.number()
|
|
87
|
+
break
|
|
88
|
+
case 'integer':
|
|
89
|
+
zodType = z.number().int()
|
|
90
|
+
break
|
|
91
|
+
case 'boolean':
|
|
92
|
+
zodType = z.boolean()
|
|
93
|
+
break
|
|
94
|
+
case 'array':
|
|
95
|
+
zodType = z.array(prop.items ? jsonSchemaPropertyToZod(prop.items, true) : z.any())
|
|
96
|
+
break
|
|
97
|
+
default:
|
|
98
|
+
zodType = z.any()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (prop.description) zodType = zodType.describe(prop.description)
|
|
102
|
+
if (!isRequired) zodType = zodType.optional()
|
|
103
|
+
return zodType
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function jsonSchemaToZodShape (jsonSchema) {
|
|
107
|
+
if (!jsonSchema || jsonSchema.type !== 'object' || !jsonSchema.properties) {
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
const required = new Set(jsonSchema.required || [])
|
|
111
|
+
const shape = {}
|
|
112
|
+
for (const [key, prop] of Object.entries(jsonSchema.properties)) {
|
|
113
|
+
shape[key] = jsonSchemaPropertyToZod(prop, required.has(key))
|
|
114
|
+
}
|
|
115
|
+
return shape
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildResourceMeta (resourceMeta) {
|
|
119
|
+
if (!resourceMeta) return undefined
|
|
120
|
+
|
|
121
|
+
const ui = {}
|
|
122
|
+
const src = resourceMeta.ui || resourceMeta
|
|
123
|
+
if (src.csp) ui.csp = src.csp
|
|
124
|
+
if (src.permissions) ui.permissions = src.permissions
|
|
125
|
+
if (src.domain !== undefined) ui.domain = src.domain
|
|
126
|
+
if (src.prefersBorder !== undefined) ui.prefersBorder = src.prefersBorder
|
|
127
|
+
|
|
128
|
+
return Object.keys(ui).length > 0 ? { ui } : undefined
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function generateEdsWidgetHtml (config) {
|
|
132
|
+
if (config?.widget_type !== 'EDS') return null
|
|
133
|
+
|
|
134
|
+
const edsWidget = config.eds_widget
|
|
135
|
+
if (!edsWidget?.script_url || !edsWidget?.widget_embed_url) {
|
|
136
|
+
console.warn(` ⚠ EDS widget for "${config.name}": missing script_url or widget_embed_url in eds_widget config`)
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return `<script src="${edsWidget.script_url}" type="module"></script>\n<div>\n <aem-embed url="${edsWidget.widget_embed_url}"></aem-embed>\n</div>\n`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Wrap a tool handler with analytics instrumentation.
|
|
145
|
+
*
|
|
146
|
+
* Captures per-call timing/size into a per-invocation buffer that the
|
|
147
|
+
* server flushes as one signed batch at end-of-request. Inputs and outputs
|
|
148
|
+
* are NEVER serialized — only byte sizes via safeSize().
|
|
149
|
+
*
|
|
150
|
+
* If analyticsConfig is absent (no buffer) the original handler is returned
|
|
151
|
+
* unchanged so unconfigured deploys cost zero overhead.
|
|
152
|
+
*/
|
|
153
|
+
function wrapHandlerWithAnalytics (name, handler, analyticsConfig) {
|
|
154
|
+
if (!analyticsConfig || !analyticsConfig.buffer) return handler
|
|
155
|
+
|
|
156
|
+
return async function instrumented (...args) {
|
|
157
|
+
const startedAt = Date.now()
|
|
158
|
+
const occurredAt = new Date(startedAt).toISOString()
|
|
159
|
+
const inputSize = safeSize(args[0])
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const result = await handler(...args)
|
|
163
|
+
analyticsConfig.buffer.push({
|
|
164
|
+
occurred_at: occurredAt,
|
|
165
|
+
tool_name: name,
|
|
166
|
+
duration_ms: Date.now() - startedAt,
|
|
167
|
+
status: 'ok',
|
|
168
|
+
input_size_bytes: inputSize,
|
|
169
|
+
output_size_bytes: safeSize(result),
|
|
170
|
+
mcp_method: 'tools/call'
|
|
171
|
+
})
|
|
172
|
+
return result
|
|
173
|
+
} catch (err) {
|
|
174
|
+
const errorClass = (err && err.constructor && err.constructor.name) || 'Error'
|
|
175
|
+
analyticsConfig.buffer.push({
|
|
176
|
+
occurred_at: occurredAt,
|
|
177
|
+
tool_name: name,
|
|
178
|
+
duration_ms: Date.now() - startedAt,
|
|
179
|
+
status: 'error',
|
|
180
|
+
input_size_bytes: inputSize,
|
|
181
|
+
output_size_bytes: 0,
|
|
182
|
+
mcp_method: 'tools/call',
|
|
183
|
+
error_class: errorClass
|
|
184
|
+
})
|
|
185
|
+
throw err
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function registerAction (server, name, action, widgetHtml, config, analyticsConfig) {
|
|
191
|
+
const toolMeta = {}
|
|
192
|
+
|
|
193
|
+
const inputSchema = config?.inputSchema ? jsonSchemaToZodShape(config.inputSchema) : undefined
|
|
194
|
+
|
|
195
|
+
if (widgetHtml) {
|
|
196
|
+
const resourceUri = `ui://${name}/widget.html`
|
|
197
|
+
const resourceMeta = buildResourceMeta(config?.resource_meta)
|
|
198
|
+
|
|
199
|
+
server.registerResource(
|
|
200
|
+
`${name}-widget`,
|
|
201
|
+
resourceUri,
|
|
202
|
+
{ mimeType: RESOURCE_MIME_TYPE },
|
|
203
|
+
async () => ({
|
|
204
|
+
contents: [{
|
|
205
|
+
uri: resourceUri,
|
|
206
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
207
|
+
text: widgetHtml,
|
|
208
|
+
...(resourceMeta ? { _meta: resourceMeta } : {})
|
|
209
|
+
}]
|
|
210
|
+
})
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
toolMeta.ui = { resourceUri, ...(config?.tool_meta?.ui || {}) }
|
|
214
|
+
toolMeta['ui/resourceUri'] = resourceUri
|
|
215
|
+
toolMeta['openai/outputTemplate'] = resourceUri
|
|
216
|
+
toolMeta['openai/resultCanProduceWidget'] = true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (config?.tool_meta) {
|
|
220
|
+
for (const [key, value] of Object.entries(config.tool_meta)) {
|
|
221
|
+
if (key !== 'ui') {
|
|
222
|
+
toolMeta[key] = value
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Per-action gate: only wrap when the action explicitly opts in AND the
|
|
228
|
+
// runtime has analytics configured. Either gate failing => bare handler.
|
|
229
|
+
const wrappedHandler = (config?.analyticsEnabled === true && analyticsConfig)
|
|
230
|
+
? wrapHandlerWithAnalytics(name, action.handler, analyticsConfig)
|
|
231
|
+
: action.handler
|
|
232
|
+
|
|
233
|
+
server.registerTool(name, {
|
|
234
|
+
title: config?.title || name,
|
|
235
|
+
description: config?.description || name,
|
|
236
|
+
inputSchema,
|
|
237
|
+
annotations: config?.annotations || undefined,
|
|
238
|
+
_meta: Object.keys(toolMeta).length > 0 ? toolMeta : undefined
|
|
239
|
+
}, wrappedHandler)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Load actions config from actions.json, keyed by action name.
|
|
244
|
+
* Looks for actions.json at actionsDir/../actions.json (the app root).
|
|
245
|
+
*/
|
|
246
|
+
function loadActionsConfig (actionsDir) {
|
|
247
|
+
const configPath = path.resolve(actionsDir, '..', 'actions.json')
|
|
248
|
+
try {
|
|
249
|
+
if (fs.existsSync(configPath)) {
|
|
250
|
+
const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
251
|
+
const map = {}
|
|
252
|
+
for (const act of raw.actions || []) {
|
|
253
|
+
if (act.name) map[act.name] = act
|
|
254
|
+
}
|
|
255
|
+
console.log(`Loaded actions.json with ${Object.keys(map).length} action(s)`)
|
|
256
|
+
return map
|
|
257
|
+
}
|
|
258
|
+
} catch (e) {
|
|
259
|
+
console.warn('Failed to load actions.json:', e.message)
|
|
260
|
+
}
|
|
261
|
+
return {}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Resolve actions from webpack-resolved context maps into a plain array.
|
|
266
|
+
*
|
|
267
|
+
* Returns [{ name, action, widgetHtml, config }] — no McpServer param.
|
|
268
|
+
* Logs startup info once. Intended to be called at createMain() time so
|
|
269
|
+
* logs fire on cold start only, not on every request.
|
|
270
|
+
*
|
|
271
|
+
* actionsConfig — raw actions.json: { actions: [{ name, ... }, ...] }
|
|
272
|
+
* Pass require('./actions.json') directly from entry.js.
|
|
273
|
+
*/
|
|
274
|
+
function resolveActionsFromContexts ({ moduleContext, htmlContext, actionsConfig }) {
|
|
275
|
+
const moduleMap = {}
|
|
276
|
+
for (const key of moduleContext.keys()) {
|
|
277
|
+
moduleMap[key.split('/')[1]] = moduleContext(key)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const widgetMap = {}
|
|
281
|
+
for (const key of htmlContext.keys()) {
|
|
282
|
+
widgetMap[key.split('/')[1]] = htmlContext(key)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const resolved = []
|
|
286
|
+
const configActions = (actionsConfig && actionsConfig.actions || []).filter(a => a && a.name)
|
|
287
|
+
|
|
288
|
+
if (configActions.length > 0) {
|
|
289
|
+
console.log(`Registering ${configActions.length} action(s) from actions.json`)
|
|
290
|
+
|
|
291
|
+
for (const config of configActions) {
|
|
292
|
+
const name = config.name
|
|
293
|
+
try {
|
|
294
|
+
const mod = moduleMap[name] || moduleMap[name.replace(/_/g, '-')]
|
|
295
|
+
const action = mod && validateAction(mod, name)
|
|
296
|
+
? normalizeAction(mod)
|
|
297
|
+
: { handler: createDefaultHandler() }
|
|
298
|
+
const widgetHtml = widgetMap[name] || widgetMap[name.replace(/_/g, '-')] || generateEdsWidgetHtml(config)
|
|
299
|
+
|
|
300
|
+
const hasHandler = !!mod
|
|
301
|
+
if (widgetMap[name] || widgetMap[name.replace(/_/g, '-')]) {
|
|
302
|
+
console.log(` ✓ ${name} (${hasHandler ? 'handler' : 'default'} + widget)`)
|
|
303
|
+
} else if (widgetHtml) {
|
|
304
|
+
console.log(` ✓ ${name} (${hasHandler ? 'handler' : 'default'} + EDS widget)`)
|
|
305
|
+
} else {
|
|
306
|
+
console.log(` ✓ ${name} (${hasHandler ? 'handler' : 'default'}, tool only)`)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
resolved.push({ name, action, widgetHtml, config })
|
|
310
|
+
} catch (error) {
|
|
311
|
+
console.error(`Error resolving action "${name}":`, error.message)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
console.warn(MISSING_CONFIG_BANNER)
|
|
316
|
+
|
|
317
|
+
const modules = moduleContext.keys()
|
|
318
|
+
console.log(`Discovering ${modules.length} action(s) from filesystem`)
|
|
319
|
+
|
|
320
|
+
for (const key of modules) {
|
|
321
|
+
try {
|
|
322
|
+
const mod = moduleContext(key)
|
|
323
|
+
const dirName = key.split('/')[1]
|
|
324
|
+
|
|
325
|
+
if (!validateAction(mod, key)) continue
|
|
326
|
+
|
|
327
|
+
const action = normalizeAction(mod)
|
|
328
|
+
const widgetHtml = widgetMap[dirName] || null
|
|
329
|
+
|
|
330
|
+
if (widgetHtml) {
|
|
331
|
+
console.log(` ✓ Loaded action: ${dirName} (tool + widget)`)
|
|
332
|
+
} else {
|
|
333
|
+
console.log(` ✓ Loaded action: ${dirName} (tool only)`)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
resolved.push({ name: dirName, action, widgetHtml, config: undefined })
|
|
337
|
+
} catch (error) {
|
|
338
|
+
console.error(`Error loading action from ${key}:`, error.message)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return resolved
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Resolve actions from the filesystem into a plain array.
|
|
348
|
+
*
|
|
349
|
+
* Returns [{ name, action, widgetHtml, config }] — no McpServer param.
|
|
350
|
+
* Logs startup info once. Intended to be called at createMain() time.
|
|
351
|
+
*/
|
|
352
|
+
function resolveActionsFromFs (actionsDir, actionsConfig) {
|
|
353
|
+
const resolved = []
|
|
354
|
+
const configNames = Object.keys(actionsConfig)
|
|
355
|
+
|
|
356
|
+
if (configNames.length > 0) {
|
|
357
|
+
console.log(`Registering ${configNames.length} action(s) from actions.json`)
|
|
358
|
+
|
|
359
|
+
for (const name of configNames) {
|
|
360
|
+
try {
|
|
361
|
+
const config = actionsConfig[name]
|
|
362
|
+
const dirName = fs.existsSync(path.join(actionsDir, name)) ? name : name.replace(/_/g, '-')
|
|
363
|
+
const indexPath = path.join(actionsDir, dirName, 'index.js')
|
|
364
|
+
const hasHandler = fs.existsSync(indexPath)
|
|
365
|
+
|
|
366
|
+
const action = hasHandler
|
|
367
|
+
? (mod => validateAction(mod, name) ? normalizeAction(mod) : { handler: createDefaultHandler() })(require(indexPath))
|
|
368
|
+
: { handler: createDefaultHandler() }
|
|
369
|
+
|
|
370
|
+
const widgetPath = path.join(actionsDir, dirName, 'widget.html')
|
|
371
|
+
const hasWidgetFile = fs.existsSync(widgetPath)
|
|
372
|
+
const widgetHtml = hasWidgetFile
|
|
373
|
+
? fs.readFileSync(widgetPath, 'utf-8')
|
|
374
|
+
: generateEdsWidgetHtml(config)
|
|
375
|
+
|
|
376
|
+
if (hasWidgetFile) {
|
|
377
|
+
console.log(` ✓ ${name} (${hasHandler ? 'handler' : 'default'} + widget)`)
|
|
378
|
+
} else if (widgetHtml) {
|
|
379
|
+
console.log(` ✓ ${name} (${hasHandler ? 'handler' : 'default'} + EDS widget)`)
|
|
380
|
+
} else {
|
|
381
|
+
console.log(` ✓ ${name} (${hasHandler ? 'handler' : 'default'}, tool only)`)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
resolved.push({ name, action, widgetHtml, config })
|
|
385
|
+
} catch (error) {
|
|
386
|
+
console.error(`Error resolving action "${name}":`, error.message)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
console.warn(MISSING_CONFIG_BANNER)
|
|
391
|
+
|
|
392
|
+
if (!fs.existsSync(actionsDir)) {
|
|
393
|
+
console.warn(`Actions directory not found: ${actionsDir}`)
|
|
394
|
+
return resolved
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const dirs = fs.readdirSync(actionsDir, { withFileTypes: true })
|
|
398
|
+
.filter(d => d.isDirectory())
|
|
399
|
+
.map(d => d.name)
|
|
400
|
+
|
|
401
|
+
console.log(`Discovering ${dirs.length} action(s) from ${actionsDir}`)
|
|
402
|
+
|
|
403
|
+
for (const dirName of dirs) {
|
|
404
|
+
try {
|
|
405
|
+
const indexPath = path.join(actionsDir, dirName, 'index.js')
|
|
406
|
+
if (!fs.existsSync(indexPath)) {
|
|
407
|
+
console.warn(`Skipping ${dirName}: no index.js found`)
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const mod = require(indexPath)
|
|
412
|
+
if (!validateAction(mod, dirName)) continue
|
|
413
|
+
|
|
414
|
+
const action = normalizeAction(mod)
|
|
415
|
+
const widgetPath = path.join(actionsDir, dirName, 'widget.html')
|
|
416
|
+
const hasWidgetFile = fs.existsSync(widgetPath)
|
|
417
|
+
const widgetHtml = hasWidgetFile ? fs.readFileSync(widgetPath, 'utf-8') : null
|
|
418
|
+
|
|
419
|
+
if (hasWidgetFile) {
|
|
420
|
+
console.log(` ✓ Loaded action: ${dirName} (tool + widget)`)
|
|
421
|
+
} else {
|
|
422
|
+
console.log(` ✓ Loaded action: ${dirName} (tool only)`)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
resolved.push({ name: dirName, action, widgetHtml, config: undefined })
|
|
426
|
+
} catch (error) {
|
|
427
|
+
console.error(`Error loading action from ${dirName}:`, error.message)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return resolved
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Resolve actions from either webpack contexts or the filesystem.
|
|
437
|
+
*
|
|
438
|
+
* Picks the right path based on options:
|
|
439
|
+
* - options.moduleContext present → resolveActionsFromContexts
|
|
440
|
+
* - otherwise → resolveActionsFromFs
|
|
441
|
+
*
|
|
442
|
+
* Returns [{ name, action, widgetHtml, config }].
|
|
443
|
+
* Call once at createMain() time; pass the result to createMcpServer().
|
|
444
|
+
*/
|
|
445
|
+
function resolveActions (options) {
|
|
446
|
+
if (options.moduleContext !== undefined) {
|
|
447
|
+
return resolveActionsFromContexts({
|
|
448
|
+
moduleContext: options.moduleContext,
|
|
449
|
+
htmlContext: options.htmlContext || { keys: () => [] },
|
|
450
|
+
actionsConfig: options.actionsConfig || {}
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
const actionsDir = options.actionsDir || path.join(process.cwd(), 'actions')
|
|
454
|
+
const cfg = loadActionsConfig(actionsDir)
|
|
455
|
+
return resolveActionsFromFs(actionsDir, cfg)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Register actions from webpack-resolved context maps onto an McpServer.
|
|
460
|
+
*
|
|
461
|
+
* Thin wrapper around resolveActionsFromContexts + registerAction.
|
|
462
|
+
* Used directly in tests and anywhere a server instance is already available.
|
|
463
|
+
* In production (createMain), prefer resolveActions + registerAction loop
|
|
464
|
+
* so resolution happens once at startup rather than per-request.
|
|
465
|
+
*/
|
|
466
|
+
function loadActionsFromContexts (server, { moduleContext, htmlContext, actionsConfig }, analyticsConfig) {
|
|
467
|
+
const resolved = resolveActionsFromContexts({ moduleContext, htmlContext, actionsConfig })
|
|
468
|
+
for (const { name, action, widgetHtml, config } of resolved) {
|
|
469
|
+
registerAction(server, name, action, widgetHtml, config, analyticsConfig)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Filesystem-based action loading.
|
|
475
|
+
*
|
|
476
|
+
* Thin wrapper around resolveActionsFromFs + registerAction.
|
|
477
|
+
* Used in Jest tests and plain Node environments without webpack.
|
|
478
|
+
*/
|
|
479
|
+
function loadActionsFromFs (server, actionsDir, actionsConfig, analyticsConfig) {
|
|
480
|
+
const resolved = resolveActionsFromFs(actionsDir, actionsConfig)
|
|
481
|
+
for (const { name, action, widgetHtml, config } of resolved) {
|
|
482
|
+
registerAction(server, name, action, widgetHtml, config, analyticsConfig)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
module.exports = {
|
|
487
|
+
resolveActions,
|
|
488
|
+
loadActionsFromContexts,
|
|
489
|
+
loadActionsFromFs,
|
|
490
|
+
loadActionsConfig,
|
|
491
|
+
createDefaultHandler,
|
|
492
|
+
generateEdsWidgetHtml,
|
|
493
|
+
registerAction,
|
|
494
|
+
wrapHandlerWithAnalytics,
|
|
495
|
+
RESOURCE_MIME_TYPE
|
|
496
|
+
}
|
package/src/local.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
* Local development HTTP server for MCP Apps.
|
|
15
|
+
*
|
|
16
|
+
* Wraps a main() function (from createMain) in a plain Node.js HTTP server so
|
|
17
|
+
* developers can run without Adobe I/O Runtime credentials.
|
|
18
|
+
*
|
|
19
|
+
* Usage in consumer app server/local.js:
|
|
20
|
+
*
|
|
21
|
+
* const { createLocalServer } = require('@adobe/llm-apps-runtime')
|
|
22
|
+
* const { main } = require('../dist/index.js') // webpack bundle
|
|
23
|
+
* createLocalServer(main, process.env.PORT || 9080)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const http = require('http')
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Start a local HTTP server that routes plain HTTP requests to an MCP main() function.
|
|
30
|
+
*
|
|
31
|
+
* @param {Function} main The main(params) function returned by createMain
|
|
32
|
+
* @param {number} port Port to listen on (default 9080)
|
|
33
|
+
* @returns {http.Server}
|
|
34
|
+
*/
|
|
35
|
+
function createLocalServer (main, port) {
|
|
36
|
+
// Use ?? (not ||) so an explicit 0 (ephemeral port, useful for tests) is honored.
|
|
37
|
+
const listenPort = port ?? 9080
|
|
38
|
+
|
|
39
|
+
const server = http.createServer(async (req, res) => {
|
|
40
|
+
const chunks = []
|
|
41
|
+
req.on('data', chunk => chunks.push(chunk))
|
|
42
|
+
req.on('end', async () => {
|
|
43
|
+
const rawBody = Buffer.concat(chunks).toString('utf8')
|
|
44
|
+
|
|
45
|
+
const params = {
|
|
46
|
+
__ow_method: req.method.toLowerCase(),
|
|
47
|
+
__ow_path: req.url,
|
|
48
|
+
__ow_body: rawBody || undefined,
|
|
49
|
+
__ow_headers: req.headers,
|
|
50
|
+
LOG_LEVEL: process.env.LOG_LEVEL || 'info'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const result = await main(params)
|
|
55
|
+
|
|
56
|
+
res.statusCode = result.statusCode || 200
|
|
57
|
+
for (const [key, value] of Object.entries(result.headers || {})) {
|
|
58
|
+
res.setHeader(key, value)
|
|
59
|
+
}
|
|
60
|
+
res.end(result.body || '')
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error('Unhandled error:', err)
|
|
63
|
+
res.statusCode = 500
|
|
64
|
+
res.setHeader('Content-Type', 'application/json')
|
|
65
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
server.listen(listenPort, () => {
|
|
71
|
+
// When listenPort is 0, the OS picks the real port — read it back.
|
|
72
|
+
const actualPort = server.address().port
|
|
73
|
+
console.log('')
|
|
74
|
+
console.log(' LLM Apps local server running')
|
|
75
|
+
console.log(` Endpoint: http://localhost:${actualPort}`)
|
|
76
|
+
console.log('')
|
|
77
|
+
console.log(' To test:')
|
|
78
|
+
console.log(` curl -sX POST http://localhost:${actualPort} \\`)
|
|
79
|
+
console.log(' -H \'content-type: application/json\' \\')
|
|
80
|
+
console.log(' -H \'accept: application/json;q=1.0, text/event-stream;q=0.5\' \\')
|
|
81
|
+
console.log(' -d \'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}\' | python3 -m json.tool')
|
|
82
|
+
console.log('')
|
|
83
|
+
console.log(' MCP Inspector: npx @modelcontextprotocol/inspector')
|
|
84
|
+
console.log(` Inspector URL: http://localhost:${actualPort}`)
|
|
85
|
+
console.log('')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
return server
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { createLocalServer }
|