Skip to main content
Glama

Salla MCP Demo: contract-driven Code Mode

A teaching project showing how to build a scalable MCP server for a real e-commerce API and an agent that uses it. It has four parts:

Part

Folder

Command

What it does

MCP server

src/mcp_server/

uv run salla-mcp serve

One generic engine for the Salla Admin API. Everything it can do comes from task contracts: YAML and Markdown files that partners add without writing code, including the screens the agent can show (A2UI views).

Chat agent

src/chat_agent/

uv run salla-agent

An OpenAI-powered web chat that uses the server. You approve every change it wants to make to your store.

Task Contract Studio

src/task_studio/

uv run salla-studio

A form for building, previewing, validating and installing task contracts on the running server.

A2UI renderer

src/a2ui_renderer/

(served by the two web apps)

A small plain-JavaScript renderer for A2UI v0.9.1 screens.

⚠️ This project calls the real Salla API. Approved changes modify a real store, so use a Salla demo store while learning.

Contents:

  1. The idea in one picture

  2. Reproduce it exactly

  3. Configuration reference

  4. How it works

  5. Built for 100+ tasks

  6. Project tour

  7. Build a similar project from scratch

  8. Tests

  9. Troubleshooting

  10. Versions, limits and history

Deeper reading: docs/architecture.md (flows, safety, scaling) and docs/adding-a-task.md (the partner guide).


1. The idea in one picture

flowchart LR
    subgraph P["Partner files (data, no code)"]
      T["tasks/categories/<br/>task.yaml · functions.yaml · ui.yaml<br/>prompts/*.md · skills/*/SKILL.md"]
    end
    subgraph E["mcp_server engine (code, written once)"]
      C["catalog<br/>(validated)"] --> S["search · describe"]
      C --> X["call · execute<br/>(Monty sandbox)"]
      X -->|reads| H["HTTP executor"]
      X -->|writes| PL["change plan"]
      PL --> A["apply_changes<br/>ONE approval gate"] --> H
      C --> V["render_ui<br/>(A2UI views)"]
    end
    T --> C
    H <-->|HTTPS| API["Salla Admin API"]
    AG["chat_agent<br/>(OpenAI)"] <-->|"MCP · Streamable HTTP"| E
    B["Browser<br/>(draws A2UI)"] <-->|WebSocket| AG
    ST["task_studio"] -->|"admin API"| C

Five ideas carry the whole design:

  1. Tasks are data, the engine is code. Adding a business domain means adding a folder of YAML and Markdown. The server, the agent and the Studio don't change.

  2. Code Mode. The LLM gets 6 fixed tools, whatever the number of tasks. It finds functions with search, reads their typed signatures with describe, and combines them in short Python scripts that run in a secure sandbox (Monty).

  3. One approval gate, one HITL broker. Every write is staged into a change plan. Nothing changes in the store until the human approves the whole plan through MCP elicitation. On the agent side, one small file, the HITL broker, handles every interrupt and resume, for any task.

  4. Planned screens. Partners design each screen ahead of time in A2UI. The LLM only fills in the data, and button clicks come back to the LLM.

  5. Built for scale. The prompt sent on every LLM call doesn't grow with the tasks; everything else is loaded on demand. A CI test with 100 generated tasks enforces this.


Related MCP server: Shopify Store MCP Server

2. Reproduce it exactly

2.1 The environment it was built and tested with

Item

Version used

Notes

OS

Windows 11 Pro

Commands below are given for bash and PowerShell

Python

3.13.12

pyproject.toml requires ≥ 3.11. uv downloads a suitable Python if you have none

uv

0.11.29

Installs the exact dependency versions from uv.lock

Node.js

22.14

Optional, only to run an MCP inspector with npx

Browser

Edge / Chrome

Needs internet access for two CDN scripts: marked 12.0.2 and DOMPurify 3.1.6

Key libraries, pinned in uv.lock:

Library

Version

Used for

mcp

2.2.0

MCP Python SDK: MCPServer (server) and Client (agent), Streamable HTTP, elicitation

pydantic-monty

0.0.23

The Code Mode sandbox (pre-1.0, so pinned exactly)

openai

3.13.0

Responses API (the agent's LLM)

fastapi / starlette / uvicorn

0.141.1 / 1.6.0 / 0.52.4

The chat and Studio web apps, WebSocket

httpx

0.28.1

Calls to the Salla API

pydantic / pydantic-settings

2.13.5 / 2.15.0

Contract models, settings from .env

pyyaml

6.0.3

Contract files (uses libyaml's C parser when available)

jsonschema / referencing

4.26.0 / 0.37.0

Argument validation, and A2UI schema checks

pytest / pytest-asyncio / respx

9.1.1 / 1.4.0 / 0.23.1

Tests, with the Salla API mocked

Protocols and services:

  • MCP over Streamable HTTP;

  • A2UI v0.9.1 with the basic catalog (the official schemas are copied into src/mcp_server/engine/a2ui_spec/);

  • OpenAI Responses API with the model gpt-5.5;

  • Salla Admin API v2 (https://api.salla.dev/admin/v2).

2.2 Accounts you need

  1. A Salla demo store and an access token.

    1. Create a free account on the Salla Partners portal, and create a demo store.

    2. Create an app with the scopes categories.read and categories.read_write.

    3. Install the app on the demo store and get its access token. See Salla's authorization guide. In Easy mode, the token arrives in the app.store.authorize webhook event.

    4. Tokens expire after about 14 days. A 401 means it's time for a new one.

  2. An OpenAI API key with access to gpt-5.5, the default. Other models that support the Responses API and function calling can be set with OPENAI_MODEL, but only gpt-5.5 was tested.

The tests need neither: Salla is mocked, and the LLM is scripted.

2.3 Step by step

1. Get the code and install the exact versions.

git clone https://github.com/jamalla/salla-api-operation-mcp-task.git
cd salla-api-operation-mcp-task
uv sync --locked            # creates .venv with the versions from uv.lock

2. Create your .env, from the repository root. Every app reads ./.env from the folder it's started in.

cp .env.example .env                    # PowerShell: Copy-Item .env.example .env
uv run python -c "import secrets; print(secrets.token_hex(24))"   # a value for MCP_ADMIN_TOKEN

Fill in at least these three:

SALLA_ACCESS_TOKEN=...        # from 2.2
OPENAI_API_KEY=sk-...         # from 2.2
MCP_ADMIN_TOKEN=...           # the random value above; the Studio needs it

3. Check the contracts and run the tests. Neither needs a token.

uv run salla-mcp validate     # → OK categories: 8 functions, 3 prompts, 1 skills, 2 views
uv run pytest                 # → 90 passed

4. Start the three apps, each in its own terminal, from the repository root:

uv run salla-mcp serve        # MCP server   → http://127.0.0.1:8001/mcp   (+ /admin/tasks)
uv run salla-agent            # chat agent   → http://127.0.0.1:8000
uv run salla-studio           # the Studio   → http://127.0.0.1:8020       (optional)

The MCP server prints:

  Salla MCP server → http://127.0.0.1:8001/mcp
  Profiles         → none (set MCP_PROFILES)
  Admin API        → http://127.0.0.1:8001/admin/tasks

5. Open http://127.0.0.1:8000 and try:

  • "List my hidden categories": searchdescribeexecute, and no approval is needed.

  • "Create Pets with a sub-category Cat food": one approval card with 2 changes. Try Reject, then Approve.

  • "Show my hidden categories as cards": the category_list view is drawn.

    • Click Details: the detail view appears.

    • Edit the name and click Save name: an approval card shows the new name.

  • In the sidebar, Prompts → Build a category tree.

  • Write in Arabic: «اعرض الأقسام المخفية».

Under each answer, the chat shows what the turn cost, for example: 4 LLM calls · 7,493 input tokens (1,792 cached) · 252 output.

2.4 Check that it works

Check

Expected

uv run salla-mcp validate

OK categories: 8 functions, 3 prompts, 1 skills, 2 views

uv run pytest

90 passed

curl -H "Authorization: Bearer <MCP_ADMIN_TOKEN>" http://127.0.0.1:8001/admin/tasks

JSON with "ok": true and the categories task, including its 8 functions and 2 views

Chat sidebar

connected · gpt-5.5; the 6 MCP tools; the categories functions and 2 view rows; 3 prompts; 1 skill

Studio (http://127.0.0.1:8020)

The brands template form, with a live UI views preview (two cards with a Rename button)

MCP inspector (optional)

npx @mcpjam/inspector@latest, or npx @modelcontextprotocol/inspector. Choose Streamable HTTP, URL http://127.0.0.1:8001/mcp, and you get 6 tools, 3 prompts and 2 resources


3. Configuration reference

Every setting is an environment variable, usually set in .env (see .env.example). The three apps read the same .env, from the folder they're started in.

MCP server (src/mcp_server/config.py):

Variable

Default

Meaning

SALLA_ACCESS_TOKEN

(required to serve)

Token of the Salla app installed on your store

SALLA_BASE_URL

https://api.salla.dev/admin/v2

Salla Admin API

TASKS_DIR

<repo>/tasks

Where the task folders are

ENABLED_TASKS

(empty = all)

Comma-separated tasks to load, e.g. categories

MCP_HOST / MCP_PORT

127.0.0.1 / 8001

Streamable HTTP endpoint: http://MCP_HOST:MCP_PORT/mcp

MCP_PAGE_SIZE

50

Items per page for MCP lists and search()

MCP_PROFILES

{}

JSON, e.g. {"catalog": ["categories"]}. A client at /mcp?profile=catalog sees only those tasks

MCP_ADMIN_TOKEN

(empty = admin API off)

Bearer token for /admin/tasks, used by the Studio

SANDBOX_MAX_SECONDS

10

Time limit per execute() script

SANDBOX_MAX_MEMORY_MB

128

Memory limit per script

PLAN_TTL_SECONDS

900

Staged change plans expire after 15 minutes

Chat agent (src/chat_agent/config.py):

Variable

Default

Meaning

OPENAI_API_KEY

(required)

OpenAI key

OPENAI_MODEL

gpt-5.5

Model for the Responses API

MAX_AGENT_STEPS

20

Maximum LLM ⇄ tool round trips per user message

MAX_TOOL_OUTPUT_CHARS

20000

Longer tool results are cut before the LLM reads them

MCP_SERVER_URL

http://127.0.0.1:8001/mcp

The MCP server. Add ?profile=<name> to use a profile

AGENT_HOST / AGENT_PORT

127.0.0.1 / 8000

The chat web app

Task Contract Studio (src/task_studio/app.py):

Variable

Default

Meaning

MCP_ADMIN_TOKEN

(required to install)

Must equal the server's value

MCP_SERVER_URL

http://127.0.0.1:8001/mcp

The admin API is derived from it: same host and port, path /admin

MCP_ADMIN_URL

(derived)

Set it only if the admin API is somewhere else

STUDIO_HOST / STUDIO_PORT

127.0.0.1 / 8020

The Studio web app

CLI (uv run salla-mcp …):

Command

Does

serve [--host H] [--port P]

Run the server (the default command)

validate [task folders…]

Check task folders against the contract; prints the file and field of every problem

schema

Regenerate tasks/_template/*.schema.json from the contract models


4. How it works

4.1 What a partner writes (no Python)

A task is a folder: tasks/<task>/ with task.yaml, functions.yaml, ui.yaml (optional), prompts/*.md and skills/*/SKILL.md. A function is a few lines of YAML:

- name: create                        # exposed as categories_create
  summary: Create a category, optionally under a parent.
  effect: write                       # read | write | destructive: decides the approval
  method: POST
  path: /categories
  params:                             # JSON Schema: validated, and turned into a typed signature
    type: object
    required: [name]
    properties:
      name:      { type: string }
      parent_id: { type: integer }
      status:    { type: string, enum: [active, hidden], default: hidden }
  request:
    body: { name: name, parent_id: parent_id, status: status }
  approval: "Create category “{name}” ({status})"

tasks/_template/ is the contract: JSON Schemas generated from the engine, plus commented examples. The server refuses to start on an invalid contract, and uv run salla-mcp validate shows the same messages. The full guide is docs/adding-a-task.md.

4.2 Planned screens: ui.yaml (A2UI)

A task can plan the screens the agent shows, in A2UI v0.9.1. The partner writes each layout once; the LLM only sends the data:

views:
- name: category_list                  # render_ui(view="categories_category_list", data=...)
  summary: Show several categories as cards.
  data: {type: object, required: [heading, categories], properties: {...}}   # JSON Schema, validated
  actions:                             # button events: a click goes back to the LLM
  - {name: hide_category, label: Hide this category, function: categories_update}
  components:                          # A2UI components, used exactly as written
  - {id: root, component: Column, children: [heading, cards]}
  - {id: cards, component: List, children: {path: /categories, componentId: card}}
  # …
  • Checked at load. Views must be valid A2UI, use only the components the bundled renderer can draw, and account for every binding and button.

  • Rendering. render_ui(view, data) checks the data and returns three A2UI messages (createSurface, updateComponents, updateDataModel), which the chat draws.

  • Clicks. A click sends an A2UI action back to the LLM as a [UI action] message. Any change that follows still needs your approval.

4.3 What the LLM sees: 6 tools, whatever the number of tasks

Tool

Purpose

search(query, kind, task, cursor)

Ranked search (BM25) over functions, views, prompts and skills (paginated)

describe(names)

Exact Python signatures of functions, and the data schema of views

call(function, arguments)

Run one function deterministically

execute(code)

Run a short Python script in the Monty sandbox that combines functions (Code Mode)

render_ui(view, data)

Show a planned screen filled with data; also callable inside execute

apply_changes(plan_id)

The only way the store changes. The user approves or rejects the whole plan

# what an LLM sends to execute(): reads run now; writes are only staged
pets = await categories_create(name="حيوانات أليفة", en_name="Pets")
await categories_create(name="طعام قطط", en_name="Cat food", parent_id=pets["data"]["id"])   # "$w1.id"

This produces one approval card for 2 changes. On approve, the engine applies them in order, and "$w1.id" becomes the parent's real ID. The agent also has its own tool, load_skill, which reads a skill's full text on demand.

4.4 Human in the loop: one gate in the server, one broker in the agent

Every change to the store stops and waits for a human. This happens in two places, each written once and shared by every task:

Where

File

Role

The gate (server)

api/approval.py, plus apply_changes in api/tools.py

Decides what to ask. It renders the change plan as a table and asks through MCP elicitation. apply_changes has an approval parameter the LLM can't fill in; a resolver fills it with the human's answer.

The HITL broker (agent)

chat_agent/hitl.py, about 70 lines

Handles every interrupt and resume. It pauses the tool call, asks the browser, and resumes the call with the answer.

The card (browser)

approvalCard in static/app.js

Draws any elicitation form from its JSON Schema. An approve field becomes Approve / Reject buttons; other fields become inputs.

The broker knows nothing about tasks. It never sees categories, plans or Salla. It receives an elicitation (a message and the JSON Schema of a small form) and returns the human's answer: accept with the form data, decline or cancel. A new task, or a new kind of question from the server, goes through it with no change to the agent.

How one interrupt and resume works:

sequenceDiagram
    participant L as Agent loop (agent.py)
    participant C as MCP client
    participant B as HITL broker (hitl.py)
    participant W as Browser (app.js)
    participant M as MCP server

    L->>C: call_tool("apply_changes", plan_id)
    C->>M: tools/call
    Note over M: resolver confirm_plan returns Elicit(render_plan(plan), Approval)
    M-->>C: elicitation request (message + form schema)
    C->>B: elicitation_callback(params)
    B->>W: approval_request (id, message, schema)
    Note over L,B: the tool call is paused on an asyncio.Future
    W->>B: approval_response (id, action, content), via main.py and broker.resolve
    B-->>C: ElicitResult(accept, approve=true, note)
    C-->>M: the answer
    Note over M: approved: apply the plan in order. Otherwise nothing is sent to Salla
    M-->>C: tool result
    C-->>L: the loop resumes with the result

What makes it reliable:

  • One pending question, one future. elicitation_callback creates a request id and an asyncio.Future, sends approval_request over the tab's WebSocket and awaits the future. resolve(id, action, content) completes it when the browser answers.

  • The chat keeps listening while it waits. The agent turn runs as a background task (main.py), so the WebSocket loop can deliver the answer to the paused call.

  • Answers reach the right tab. Each browser tab has its own MCP session and its own broker.

  • One question at a time. The agent asks the LLM for one tool call at a time (parallel_tool_calls=False).

  • Nothing hangs.

    • A question expires after 10 minutes: the browser gets approval_expired, and the server gets cancel.

    • Closing the tab cancels every open question (cancel_all).

    • An unknown answer counts as cancel.

    • URL-mode elicitation (e.g. "log in at this URL") is declined.

  • Fail closed. Only an accepted form with approve: true applies the plan. A rejection, decline, cancel or timeout sends nothing to Salla, and the LLM is told why, so it doesn't retry. Plans are single-use and expire after PLAN_TTL_SECONDS.

Reusing it. The broker depends only on the MCP SDK's elicitation types and a send(event) function. To use it in another client, such as a CLI, a Slack bot or a mobile app, keep hitl.py and replace the transport: whatever shows the question calls broker.resolve(...) with the answer.

4.5 The Studio and the admin API

The Studio edits a form and renders it into contract files, shown as live YAML tabs with an A2UI preview for views. It sends them to the MCP server's admin API, which validates them with the start-up rules and installs them live, with no restart. The admin API runs next to /mcp, is protected by MCP_ADMIN_TOKEN, and is not part of MCP, so LLMs never see it.

Endpoint

Purpose

GET /admin/tasks

List installed tasks

GET /admin/tasks/{name}

Get a task's contract files

POST /admin/tasks/validate

Dry run: {"files": {"task.yaml": "...", ...}}

POST /admin/tasks

Install and hot-reload ("replace": true to update)

DELETE /admin/tasks/{name}

Uninstall

4.6 Pagination at three levels

  1. MCP lists (prompts, resources) come in pages with an opaque next_cursor (MCP_PAGE_SIZE).

  2. search() returns a next_cursor.

  3. Salla's own pages: fetch_all_pages("categories_list", {...}) inside execute walks them all.


5. Built for 100+ tasks

Every LLM call sends the agent's instructions and the tool schemas again, and one user message often makes 4–6 calls. So the rule is: the fixed part of the prompt must not grow with the tasks; everything else is loaded when needed.

Sent on every LLM call

Loaded only when needed

6 tools + load_skill, server and agent instructions, the task names

functions and views (searchdescribe), a skill's text (load_skill), prompts (when the user clicks one), data (tool results)

Measured with 100 generated tasks (800 functions, 200 views, 100 skills):

Result

Fixed prompt, 1 task → 100 tasks

about 1,800 → 2,050 tokens, roughly 3 tokens per task

Listing every skill's description instead

about 5,700 extra tokens on every call

Search over 1,400 entries

about 3 ms

Server start-up with 100 tasks / reload after changing one task

about 7 s / 0.3 s

How:

  • No skill list in the prompt. The agent's prompt names the tasks; skills are found with search(kind="skill").

  • Size limits in the contract. Summaries are limited to 200 characters, action labels to 150, skill descriptions to 300, and a skill's text to 12,000.

  • Ranked search (BM25). Rare words and words in names count more, so the LLM needs fewer search rounds.

  • Small results.

    • Scripts return only the fields they need.

    • render_ui inside execute sends list data straight to the screen.

    • The agent cuts results longer than MAX_TOOL_OUTPUT_CHARS.

  • Only the stubs a script uses. execute type-checks a script against the functions it names, not against all of them.

  • Profiles. MCP_PROFILES plus …/mcp?profile=<name> gives each kind of agent only its tasks, and this is enforced for every tool, prompt and resource.

  • Incremental reloads. Installing a task re-reads only the folders whose files changed.

  • Measured. The chat shows real token usage per turn. test_token_budget.py fails if the fixed part goes over 4,000 tokens with 100 tasks or grows by more than 10 tokens per task.

Details: docs/architecture.md → Scaling to 100+ tasks.


6. Project tour

.env.example                     every setting, commented (copy to .env)
pyproject.toml / uv.lock         dependencies and their exact locked versions; the 3 CLI commands
docs/
  architecture.md                flows, the approval gate, A2UI, scaling, errors
  adding-a-task.md               the partner guide (contract fields, views, size limits, checklist)
tasks/                           ── DATA: partner task contracts (no code) ──
  categories/                    task.yaml, functions.yaml (8), ui.yaml (2 views), prompts/ (3), skills/ (1)
  _template/                     the contract: *.schema.json (generated) + a commented brands example
src/mcp_server/                  ── THE MCP SERVER (uv run salla-mcp serve) ──
  server.py                      entry point: wires engine + api; LLM instructions; CLI serve | validate | schema
  config.py                      settings from .env
  engine/                        core logic: no MCP or HTTP code
    contracts.py                 the partner contract (Pydantic): validation, request building, size limits, views
    catalog.py                   live catalog: load, validate, BM25 search, install/uninstall, CatalogScope
    runtime.py                   the Engine: call / execute / render / apply
    plans.py                     change plans: staged writes + "$wN.id" placeholders
    sandbox.py                   Monty sandbox + the typed stubs scripts are checked against
    store_client.py              HTTP to Salla: auth, retries, readable errors
    a2ui.py                      A2UI: load-time checks of views + the messages that show them
    a2ui_spec/                   the official A2UI v0.9.1 JSON Schemas (copied, Apache-2.0)
  api/                           how clients reach the engine (the only MCP / HTTP code)
    tools.py                     the 6 MCP tools
    approval.py                  the approval question and its answer (MCP elicitation)
    protocol.py                  resources, prompts, pagination, profile scoping (replaces SDK handlers)
    admin.py                     admin HTTP API: validate / install / uninstall tasks live
    profiles.py                  one endpoint per kind of agent (/mcp?profile=<name>)
src/chat_agent/                  ── THE CHAT AGENT (uv run salla-agent) ──
  main.py                        FastAPI + WebSocket; one MCP session per browser tab
  mcp_connection.py              connect by URL + paginated discovery
  agent.py                       agent loop (OpenAI Responses API), MCP ⇄ OpenAI, A2UI hand-off, usage
  hitl.py                        the HITL broker: pauses on any MCP elicitation, asks the browser, resumes with the answer
  static/                        index.html, app.js, style.css
src/task_studio/                 ── THE TASK CONTRACT STUDIO (uv run salla-studio) ──
  app.py                         the page + /api/* in front of the MCP admin API (token stays server-side)
  files.py                       draft ⇄ contract files (YAML + Markdown)
  static/                        index.html, studio.js, studio.css
src/a2ui_renderer/               ── A2UI RENDERER, shared by the chat and the Studio ──
  static/                        a2ui.js (the renderer), a2ui.css
tests/                           mcp_server/, agent/, studio/ (each app alone) + integration/ (apps together)

Rules the layout follows:

  • Isolated apps. The three apps never import each other; they talk over HTTP only. Both web apps serve the shared A2UI renderer the way they'd use a CDN script.

  • Protocol-free engine. engine/ has no MCP or HTTP imports; api/ is the only MCP code.

  • One place for SDK internals. api/protocol.py is the only module that touches them.

Concept

Where to look

Partner contract (Pydantic → JSON Schema)

engine/contracts.py, tasks/_template/

Live catalog, search, scopes

engine/catalog.py

call / execute / render / apply

engine/runtime.py

Change plans and placeholders

engine/plans.py

Code Mode sandbox + typed stubs

engine/sandbox.py

A2UI views

engine/a2ui.py, tasks/categories/ui.yaml, a2ui.js

The 6 tools

api/tools.py

Human in the loop (elicitation)

api/approval.py, chat_agent/hitl.py

Resources, prompts, pagination, profiles

api/protocol.py, api/profiles.py

Admin API

api/admin.py

Agent loop, token usage

chat_agent/agent.py


7. Build a similar project from scratch

Use this as a recipe for your own API (Shopify, Zid, an internal API…). Each step names the file here to copy ideas from.

Step 0. Set up the project.

uv init my-mcp-demo && cd my-mcp-demo
uv add "mcp==2.2.*" httpx pydantic-settings "pydantic-monty==0.0.23" pyyaml jsonschema openai fastapi "uvicorn[standard]"
uv add --dev pytest pytest-asyncio respx

Use a src/ layout with one package per app, and declare them in pyproject.toml under [tool.hatch.build.targets.wheel] packages. Define the CLI commands under [project.scripts], and set [tool.pytest.ini_options] asyncio_mode = "auto".

Steps, in the order that works best:

#

Build

Key decisions

Look at

1

The contract

Pydantic models with extra="forbid", so a typo is an error. Each function has effect (read/write/destructive), method, path, JSON-Schema params and an optional request mapping. Export JSON Schemas for partners' editors, and add size limits.

engine/contracts.py, tasks/_template/

2

The catalog

Strict loading: an invalid task stops the server and names the file and field. Live install / uninstall, re-reading only changed folders. Search over everything.

engine/catalog.py

3

The HTTP executor

Contract + arguments → request: path placeholders, query or body, dotted keys for nesting. Retries and readable errors.

FunctionContract.build_request, engine/store_client.py

4

Change plans

Writes are staged, never run. $wN.id placeholders connect writes within one plan. Apply in order, stop at the first failure, report every change.

engine/plans.py, Engine.apply

5

The sandbox

Monty: no filesystem, network or environment. Type-check scripts against generated stubs before running them, and hand the script only the functions it names.

engine/sandbox.py, functions_used

6

The MCP tools

A fixed set (search, describe, call, execute, render_ui, apply_changes), with read-only annotations. Engine errors become ToolErrors the LLM can read.

api/tools.py

7

The approval gate

A resolver (Resolve(confirm_plan)) fills apply_changes' approval parameter through elicitation, so the LLM can't approve itself. It is deterministic, so retries ask the same question.

api/tools.py, api/approval.py

8

Protocol extras

Paginated resources/list and prompts/list, prompts served from Markdown, resources/read, and profile scoping, via _lowlevel_server.add_request_handler.

api/protocol.py, api/profiles.py

9

Admin API

custom_routes next to /mcp, protected by a bearer token (compared with hmac). They validate with the start-up rules, then install.

api/admin.py

10

The agent

mcp.Client(url, elicitation_callback=…), paginated discovery, and MCP tool → OpenAI function conversion. The Responses API with previous_response_id and parallel_tool_calls=False, so one approval happens at a time.

chat_agent/agent.py, mcp_connection.py

11

Human in the loop in the browser

One WebSocket per tab. An ApprovalBroker turns elicitation into a card and waits for the answer. The agent loop runs as a background task, so answers can arrive.

chat_agent/main.py, hitl.py, static/app.js

12

Skills on demand

Skills are skill:// resources, found with search and read by the agent's load_skill tool, never listed in the prompt.

api/protocol.py, agent.py

13

A2UI views

Copy the official v0.9.1 schemas, check views at load time with a referencing registry, and build the three messages. A tiny DOM-only renderer draws them, and clicks come back as [UI action].

engine/a2ui.py, src/a2ui_renderer/

14

The Studio

A form to a draft to files, then the admin API. The token stays server-side. It uses the same renderer for previews.

src/task_studio/

15

Scaling guards

Size limits, BM25 search, profiles, clipped results, token usage, and a token-budget test with 100 generated tasks.

Built for 100+ tasks

16

Tests

Run the MCP server in memory (Client(server)), mock the API with respx, script the LLM with a fake. They need no keys and run in about 20 s.

tests/

Design rules worth keeping:

  • Tasks are data. Adding one never changes code.

  • The tool count is fixed, and the prompt must not grow with the tasks.

  • Every change passes one approval gate, which fails closed.

  • The LLM can't approve, invent screens or reach undeclared endpoints.

  • The engine has no protocol code, and apps talk over HTTP only.

  • Check everything at load time, with messages that name the file and field.


8. Tests

uv run pytest                          # everything (90 tests, about 20 s)
uv run pytest tests/mcp_server         # the server alone
uv run pytest tests/integration        # apps together (agent ⇄ server, Studio ⇄ server, token budget)

The server runs in memory, Salla's API is mocked with respx, the sandbox runs for real, and the LLM is scripted. Among other things, the tests check that:

  • a rejected plan sends no write request, and a client that can't show approval forms can't change the store;

  • placeholders become real IDs, and apply stops at the first failure;

  • type errors reach the LLM before any code runs, and the sandbox has no filesystem and a time limit;

  • contracts are strict, the template schemas match the engine, and texts respect the size limits;

  • views are valid A2UI, and render_ui only accepts data that matches the view, including from inside scripts;

  • profiles hide everything outside their tasks, and search ranks the best match first;

  • with 100 tasks, the fixed part of the prompt stays within its token budget.


9. Troubleshooting

Symptom

Fix

The agent says "Could not talk to the MCP server"

Start it with uv run salla-mcp serve, and check MCP_SERVER_URL.

The server exits with "SALLA_ACCESS_TOKEN is not set"

Create .env from .env.example, and start the app from the repository root.

The server exits with "Invalid task contract(s)"

Run uv run salla-mcp validate; the message names the file and the field.

401 / 403 from Salla

The token has expired, or the app is missing the categories.read_write scope.

The LLM's script fails with "type errors"

Expected: it gets the message back and fixes the script.

The Studio says the admin API is disabled

Put the same MCP_ADMIN_TOKEN in .env for both processes and restart them.

Unknown profile 'x'

The ?profile= in MCP_SERVER_URL must be a name from the server's MCP_PROFILES.

Windows: uv sync fails with "failed to remove file … (os error 32)"

A running app holds the .exe launcher. Stop the servers, then run uv sync again.

Windows: UnicodeEncodeError when printing Arabic

The console isn't UTF-8. Run $env:PYTHONIOENCODING="utf-8" (PowerShell) or set PYTHONIOENCODING=utf-8 (cmd) first.

The chat shows raw Markdown

The browser couldn't load marked / DOMPurify from the CDN; check internet access.


10. Versions, limits and history

History (git):

  • tag v1-per-tool: the first design, with one Python-coded MCP tool per operation;

  • the v2 commit: contract-driven Code Mode, the chat agent and the Studio;

  • the current version adds A2UI views and the scaling work in section 5.

Known limits:

  • PlanStore is in memory. With several server replicas, swap it for a Redis-backed class with the same methods.

  • Monty is pre-1.0 (0.0.23, pinned). All sandbox code lives in engine/sandbox.py.

  • Only part of A2UI is drawn. The renderer supports Column, Row, List, Card, Text, Image, Divider, Button, TextField and formatString. The server refuses views that use anything else.

  • A2UI checks are slow at start-up. With 100 tasks, start-up takes about 7 s, mostly checking views against the A2UI schemas.

  • Token counts in the budget test are estimates (4 characters per token). The chat's usage line shows real numbers from the API.

  • Reads can't see staged writes. Stage related writes together, and read after applying.

A note on the word "task": here, a task is a business domain folder (categories, brands, …). The MCP specification also has an experimental Tasks feature for long-running requests. That is a different thing, and the MCP Python SDK 2.2 used here doesn't implement it.

License and credits:

  • The repository doesn't have a license file yet.

  • The A2UI JSON Schemas in src/mcp_server/engine/a2ui_spec/ are © the A2UI authors, Apache-2.0 (see NOTICE.md).

  • The sandbox is Monty by Pydantic.

  • The MCP SDK is the official modelcontextprotocol/python-sdk.

Related MCP Connectors

Related MCP Servers