# MCP Server Architecture
Technical reference for how the Digital Signage MCP server is implemented, how a client
discovers and calls its tools, and how each tool relates to existing application code.
See [use-mcp-agent-tools.md](use-mcp-agent-tools.md) for end-user connection instructions.
## Overview
The MCP server ([web-ui/mcp_server.py](https://github.com/intel-retail/digital-signage/blob/main/web-ui/mcp_server.py)) runs in-process
alongside the Flask app in the `web-ui` container, on port `5100`, using the `FastMCP`
Streamable HTTP transport. It is started as a daemon thread from
[web-ui/main.py](https://github.com/intel-retail/digital-signage/blob/main/web-ui/main.py) after `initialize_app()` completes, so a
failure to import `mcp` or bind the port only disables the MCP thread — Flask, MQTT,
and AIG keep running.
Because it runs in the same process as `main.py`, tool implementations call shared
in-memory state directly (via `sys.modules['__main__']`) instead of making HTTP
requests to the REST API.
Externally, the server is reached through the nginx proxy at `/mcp`
(`configs/nginx/nginx.conf`), which forwards to `http://web-ui:5100` and terminates TLS.
## How a client discovers and calls tools
This follows the standard MCP protocol handshake — no custom discovery code:
1. **Registration**: each Python function decorated with `@mcp.tool()` in
`mcp_server.py` is auto-registered by the `FastMCP` SDK. The tool name is the
function name; the input schema is derived from the function's type-annotated
parameters; the description shown to clients is the function's docstring.
2. **`initialize`**: client opens a Streamable HTTP connection to `/mcp` and sends
`initialize`.
3. **`tools/list`**: client requests the tool catalog; the server returns all
registered tools with their descriptions and JSON-schema parameters.
4. **`tools/call`**: client invokes a tool by name with arguments; FastMCP dispatches
to the corresponding Python function and returns its string return value as the
result.
The `describe` tool exists as a convenience for agents that prefer reading a single
natural-language capability summary instead of parsing the schema.
## Tools added
| Tool | Signature | Purpose |
| --- | --- | --- |
| `describe()` | → str | Static capability summary for the agent |
| `get_current_ad()` | → str | What's currently on screen (camera / agent / generating) |
| `get_catalog()` | → str | Lists products and their cross-sell promos |
| `select_dynamic_ad()` | `(weather, demand, age_mix, daypart, display_seconds, benchmark)` → str | Resolves a shopping context to a product and displays its ad |
| `trigger_ad()` | `(item, display_seconds, promo_text, slogan)` → str | Directly displays a named catalog item's ad |
| `trigger_video_ad()` | `(item, description)` → str | Displays a looping video ad, live on screen for `VIDEO_AD_DISPLAY_SECONDS` (default 5s); exactly one of `item` (a catalog product; predefined video if provisioned, else AI-generated with catalog overlays) or `description` (free text describing the video directly) is required — not both, since their overlays/content would be unrelated; MCP-triggered only; generation runs in the background (about 25-30 s on GPU with the default `f16` precision, see [Change Inference Device](change-inference-device.md#video-precision-and-preloading)) |
| `clear_ad()` | → str | Clears the agent override, returns to camera-driven flow |
## New implementation vs. reuse of existing code
| Tool | Underlying code | New vs. reused |
| --- | --- | --- |
| `describe` | Inline string, no backing function | Fully new, MCP-only |
| `get_current_ad` | `get_active_ad_info()` | New function, but reads the existing `agent_override_*` / `last_selected_item` state — the same state `Ad_Generator.get_current_advertisement()` (used by the existing `/get_current_advertisement` REST route) already checks. Also reports the new `video_override_*` state (see `trigger_video_ad`) |
| `get_catalog` | `get_catalog_summary()` | New function built entirely on the existing `product_associations` dict, already populated from `ProductAssociations.csv` for the camera-driven flow |
| `trigger_ad` | `trigger_ad_core()` | New orchestration function; calls the pre-existing `resolve_product_label()` and the pre-existing core engine `Ad_Generator.generate_advertisement()` (same method the MQTT/camera pipeline uses). Introduces new state: `agent_override_ad/_until/_item/_generating`, `override_epoch` |
| `select_dynamic_ad` | `select_dynamic_ad_core()` + `resolve_context_to_product()` + `load_context_rules()` | New feature end-to-end (new `context_rules.json` config, new matching logic), but delegates final ad rendering to the same existing `generate_advertisement()` engine |
| `trigger_video_ad` | `trigger_video_ad_core()` + `Ad_Generator.generate_video_ad()` | Fully new, MCP-only video path with its own isolated state (`video_override_media/_until/_item/_generating`, `_video_generation_lock`). Predefined videos are looked up directly by product name (new `product_video_paths` dict from a new `pre_defined_ad_video` CSV column, mimetype detected from file extension) with no ChromaDB/AIG involvement. When no predefined video exists it calls a new AIG endpoint, `/aig/mvid/` (`Text2VideoPipeline`/LTX-Video model, separate from the existing image model), which generates genuinely temporally-related frames in a single call and returns an already-encoded looping animated WEBP (`image/webp`). AIG-side: new `videoinf.py` resource, new `AigServerMetadata` video-model config/singleton, and the promo/frame/logo overlay logic was extracted into a shared `apply_ad_overlays()` helper reused by both the image and video endpoints |
| `clear_ad` | `clear_agent_override()` | Fully new; manipulates the new override state fields |
**Notes:**
- Despite docstrings stating some core functions are "shared by the REST route and the
MCP tool," there is currently no REST route calling `trigger_ad_core` or
`select_dynamic_ad_core` — only `/`, `/portrait`, and `/get_current_advertisement`
exist as Flask routes today.
- The genuine reuse point across the camera-driven flow and the new MCP action tools
is `Ad_Generator.generate_advertisement()` — the image-generation engine that calls
the AIG server's `/ase/predef/query/ad` and `/aig/minf/` endpoints is unchanged in
interface and shared by both paths.
- On the AIG server side, the same change hardened (but did not add new endpoints to)
the existing `/aig/minf/` inference API: a `model_lock` was added for thread-safety
and the Flask app was switched to `threaded=True`, since MCP tools can now trigger
concurrent generation requests that didn't previously occur.
- `trigger_video_ad` reads its own `AIG_VIDEO_INFERENCE_DEVICE` env var (web-ui) to
pick the `device` sent to `/aig/mvid/`, kept deliberately separate from the image
path's `AIG_INFERENCE_DEVICE`. It must match the AIG server's `AIG_VIDEO_MODEL_DEVICE`
(same `.env` source variable) or the server can't reuse its preloaded video
pipeline and rebuilds it from disk on every call.
## Control flow — client connecting and calling a tool
```mermaid
---
config: {"theme": "dark"}
---
sequenceDiagram
participant Client as MCP Client
(Inspector / LLM agent)
participant Nginx as nginx proxy
(TLS, :5000)
participant MCP as MCP server thread
(FastMCP, :5100)
participant Main as main.py
(__main__ / Flask app)
participant AdGen as Ad_Generator
(generate_advertisement)
participant AIG as AIG server
(:5003)
Client->>Nginx: HTTPS POST /mcp (initialize)
Nginx->>MCP: proxy_pass http://web-ui:5100/mcp
MCP-->>Client: initialize result (capabilities)
Client->>Nginx: tools/list
Nginx->>MCP: forward
MCP-->>Client: [describe, get_current_ad, get_catalog,
select_dynamic_ad, trigger_ad, clear_ad] + schemas
Client->>Nginx: tools/call "trigger_ad" {item, display_seconds}
Nginx->>MCP: forward (proxy_buffering off, streamable-http)
MCP->>Main: _app() -> sys.modules['__main__']
MCP->>Main: trigger_ad_core(item, display_seconds, ...)
Main->>Main: resolve_product_label(item)
Main->>Main: acquire _generation_lock (non-blocking)
Main-->>MCP: {'status': 'generating', 'item': resolved, ...}
MCP-->>Client: "Ad for '- ' is generating now..." (immediate)
Note over Main,AIG: background daemon thread (async, after response sent)
Main->>AdGen: generate_advertisement(item, associations)
AdGen->>AIG: POST /ase/predef/query/ad (check predefined)
AIG-->>AdGen: predefined image (if found)
alt no predefined match
AdGen->>AIG: POST /aig/minf/ (dynamic generation)
AIG-->>AdGen: generated image bytes
end
AdGen->>Main: last_generated_ad captured
Main->>Main: agent_override_ad = captured
agent_override_until = now + secs
Main->>Main: release _generation_lock
Client->>Nginx: tools/call "get_current_ad"
Nginx->>MCP: forward
MCP->>Main: get_active_ad_info()
Main-->>MCP: {'mode': 'agent', 'item': ..., 'seconds_remaining': ...}
MCP-->>Client: "Currently showing an agent-commanded ad for '- '..."
```
## Data flow — state and payloads across components
```mermaid
---
config: {"theme": "dark"}
---
flowchart LR
subgraph Client["MCP Client"]
C1[LLM agent / Inspector]
end
subgraph Proxy["nginx (TLS termination)"]
N1["/mcp location\nproxy_pass web-ui:5100"]
end
subgraph WebUI["web-ui container"]
subgraph MCPThread["MCP server thread (:5100)"]
T1[describe]
T2[get_current_ad]
T3[get_catalog]
T4[select_dynamic_ad]
T5[trigger_ad]
T6[clear_ad]
end
subgraph SharedState["Shared in-memory state (Ad_Generator instance)"]
S1[(product_associations\nfrom ProductAssociations.csv)]
S2[(context_rules\nfrom context_rules.json)]
S3[(agent_override_ad /\n_until / _item / _generating\noverride_epoch)]
S4[(last_generated_ad\nlast_selected_item)]
S5[(_generation_lock)]
end
F1["Flask routes\n/, /portrait,\n/get_current_advertisement"]
MQ["MQTT subscriber\n(camera detections)"]
end
subgraph AIGServer["aig-server container (:5003)"]
A1["/ase/predef/query/ad\n(predefined lookup)"]
A2["/aig/minf/\n(dynamic image generation, model_lock)"]
end
C1 <-->|"HTTPS: initialize / tools-list / tools-call"| N1
N1 <-->|"HTTP proxy"| T1 & T2 & T3 & T4 & T5 & T6
T2 --> S3
T2 --> S4
T3 --> S1
T4 --> S2
T4 --> S1
T4 -->|"acquire/release"| S5
T5 --> S1
T5 -->|"acquire/release"| S5
T6 --> S3
T4 & T5 -->|generate_advertisement| A1
A1 -->|"miss"| A2
A1 & A2 -->|image bytes| S3
S3 -->|captured ad, expiry timestamp| S4
MQ -->|camera-detected item| S1
MQ -->|generate_advertisement when no override| A1
F1 -->|reads| S3
F1 -->|reads| S4
C1 -.->|"REST (unrelated path)"| F1
```
**Key control-flow points:**
- All 6 tools are dispatched through the single FastMCP thread; each calls back into
`main.py`'s module-level functions via `_app()` (same process, no HTTP hop internally).
- `trigger_ad` / `select_dynamic_ad` return immediately (status `generating`) while the
actual image generation runs in a separate daemon thread — the MCP call never blocks
on the AIG round-trip (except when `benchmark=True`).
- `_generation_lock` + `override_epoch` prevent two concurrent generations (agent vs.
camera-driven, or agent vs. a `clear_ad` mid-flight) from corrupting shared state.
**Key data-flow points:**
- `agent_override_*` fields are the single hand-off point between MCP tools and the
display: both the REST `/get_current_advertisement` route and MCP's `get_current_ad`
read the same override state.
- `product_associations` (CSV) and `context_rules` (JSON) are load-once, read-many
configuration feeding both the camera-driven MQTT path and the new MCP tools.