Development development
This section covers the handler project structure, local development workflow, and testing setup for Adobe LLM Apps. For the handler contract and example code, see Write the Action Handler.
Project structure
Your linked repository follows this layout:
your-llm-app/
├── entry.js # Webpack entry — do not modify
├── actions/ # One folder per action
│ ├── search-products/
│ │ └── index.js # Handler (async function)
│ ├── get-product-details/
│ │ └── index.js
│ └── echo/
│ └── index.js
├── test/
│ ├── actions/
│ │ └── search-products.test.js
│ ├── fixtures/
│ │ └── actions.json
│ ├── html-transform.js
│ ├── jest.setup.js
│ └── server.test.js
├── server/
│ └── local.js # Local dev server (port 9080)
├── actions.json # Gitignored — local copy of UI metadata
├── app.config.yaml # Adobe I/O Runtime config
├── webpack.config.js
└── package.json
Key points:
entry.jsis the webpack entry point. At build time it discovers everyactions/*/index.jsfile and bundles them into a singledist/index.js. Do not modify.actions.jsonis gitignored. Download it from the Actions page in the UI for local development. For deployments, the pipeline writes it automatically from the API.- Tests live under
test/actions/, not insideactions/. Webpack bundles everything underactions/into the deployed artifact — co-locating tests would ship them to Adobe I/O Runtime.
Local development
You can develop and test handlers locally without Adobe credentials:
npm install
npm run dev:local
This builds the project with webpack and starts a plain Node.js HTTP server on http://localhost:9080. The server auto-discovers your handler files under actions/ and registers them as MCP tools.
Download actions.json
For the local server to know about your action metadata (name, description, input schema), download actions.json from the Actions page in the LLM Apps UI and place it at the repository root. Without it, the server discovers your handlers but registers them with minimal metadata.
You can also copy actions.example.json to actions.json as a starting point.
Test with curl
# List all registered tools
curl -sX POST "http://localhost:9080" \
-H 'content-type: application/json' \
-H 'accept: application/json;q=1.0, text/event-stream;q=0.5' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# Call the search-products action
curl -sX POST "http://localhost:9080" \
-H 'content-type: application/json' \
-H 'accept: application/json;q=1.0, text/event-stream;q=0.5' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search-products","arguments":{"category":"bagged-coffee"}}}'
Test with MCP Inspector
npx @modelcontextprotocol/inspector
Set Transport Type to streamable-http and URL to http://localhost:9080.
Testing
Handler unit tests live under test/actions/ and mirror the actions/ layout:
// test/actions/search-products.test.js
const handler = require('../../actions/search-products/index.js')
test('returns all products when no filter is given', async () => {
const result = await handler({})
expect(result.content[0].text).toContain('product')
expect(result.structuredContent.products.length).toBeGreaterThan(0)
})
test('filters by category', async () => {
const result = await handler({ category: 'bagged-coffee' })
expect(result.structuredContent.products.every(
(p) => p.category === 'bagged-coffee'
)).toBe(true)
})
test('filters by query', async () => {
const result = await handler({ query: 'dark-roast' })
expect(result.structuredContent.products.length).toBeGreaterThan(0)
})
test('returns empty result for unknown category', async () => {
const result = await handler({ category: 'nonexistent' })
expect(result.structuredContent.products).toHaveLength(0)
})
Run tests with:
npm test # all tests
npx jest test/actions/search-products # one action only
Deployment
You do not build or deploy manually. For a full walkthrough of the deployment pipeline, see Deploy Your App.
Your day-to-day workflow is:
actions/<name>/index.jsnpm run dev:localgit push