MCP Webhook Event API Reference#
The MCP server exposes an HTTP webhook POST /events that ingests pipeline events pushed by any upstream video-analytics client. This document is the server-side contract: as long as a client follows it, the MCP server applies the same parsing, DB writes, and downstream pipeline rules regardless of who the sender is. The production sender is videostream-analytics, but the same protocol applies equally to third-party analytics services and replay tools.
1. Endpoint#
Item |
Value |
|---|---|
Method |
|
URL |
|
Default port |
|
Content-Type |
|
Auth |
None (loopback / intranet deployment) |
Bind address |
|
Client |
Any process that can issue an HTTP POST; the production client is |
1.1 Health Probe#
Method |
URL |
Response |
|---|---|---|
|
|
|
Other paths / methods |
— |
|
1.2 Response#
All non-2xx responses are errors the client must handle. We split client errors by failure layer — transport / framing (400, 413, 415) vs. semantic / business-rule (422) — so the client can react differently without parsing error strings.
Status |
When |
Body |
DB write |
|---|---|---|---|
|
Envelope + payload valid, required fields present, DB writes succeeded. |
|
✅ |
|
Body is not valid JSON, or the envelope is structurally broken: missing/empty |
|
❌ |
|
Path does not match |
empty |
— |
|
Wrong HTTP method on a known path (e.g., |
empty |
— |
|
Request body exceeds the configured size limit (default 1 MiB, controlled by |
|
— |
|
|
|
— |
|
Envelope parses and is structurally valid, but the event is semantically unprocessable: required payload fields missing for the given |
|
❌ |
|
Envelope + payload validated, but a DB write threw an unexpected exception (disk full, schema mismatch, etc.). Logged at |
|
partial / none |
Retry policy / back-pressure:
2xx⇒ success. Continue.
4xx⇒ permanent error, do not retry the same body. The client sent something invalid and must fix it.400/413/415are framing problems;422is a payload semantic problem. Whichever it is, retrying the same bytes will produce the same response.
5xx⇒ transient server-side failure. Client may retry with the same body after a backoff (exponential with jitter is reasonable; the server keeps no idempotency state, so a successful retry produces a new DB row).No event must ever stall the pipeline. A
4xxis logged, dropped, and the client moves on to the next event. The server never blocks the upstream stream on a single bad event.
Why 422 not 400 for the missing-field / unknown-type cases? They are business-rule failures, not transport failures — the JSON parsed cleanly, the envelope is well-formed, but the body cannot be applied to a known table. Splitting them into a distinct status code lets the client tell “my serializer is broken” (400) from “my pipeline emitted a stale or new event type the server doesn’t know about” (422) without string-matching the error message. A stable code field is included in every error body for the same reason.
1.3 Request constraints#
Constraint |
Value (default) |
Configurable via |
Violation |
|---|---|---|---|
Max body size |
|
|
|
Required |
|
— |
|
Allowed methods on |
|
— |
|
Allowed methods on |
|
— |
|
Connection / read timeout |
|
|
Connection closed; no body sent |
3. type=motion#
A motion segment event. The clip has already been cut into a standalone MP4 file by the client.
3.1 Payload fields#
Field |
Type |
Required |
Description / target DB column |
|---|---|---|---|
|
|
✅ |
Absolute path to the original (uncropped) clip. Written to |
|
|
✅ |
Path to the clip fed into the video-summary service. If the client performed ROI crop, this points to |
|
|
✅ |
Clip start time. Written to |
|
|
Optional |
Clip end time. Written to |
|
|
✅ |
Clip duration in seconds (may be fractional). Written to |
|
|
Optional |
Whether the client-side prefilter (e.g., NPU YOLO) passed. This single field directly decides |
|
|
Optional |
Hit class list, must be pre-serialized into a string by the client (e.g., |
|
|
Optional |
Maximum confidence among hit classes. Written to |
|
|
Optional |
Bounding-box trajectory region for downstream ROI re-crop. Written to |
Required fields (any missing → logger.warn, no DB write, server responds 422 Unprocessable Entity with code="missing_required_fields"):
event_file_path, summary_clip_input, start_time, duration_seconds
3.2 MCP-side handling#
Two tables are written in this order:
INSERT INTO events(motion_type=motion) — always written.INSERT INTO video_summary_tasks—statusis decided byprefilter_passed:
|
|
video-worker behavior |
|---|---|---|
Field absent (no prefilter configured) |
|
Normal poll → call Video Summary Service → write summary |
|
|
Normal poll → call Video Summary Service → write summary |
|
|
Video Summary Service call skipped, row kept only for audit |
Design intent: every motion clip lands in
events(preserving the full motion timeline forstate_query/rule_eval), but clips that prefilter rejects do not waste Video Summary Service compute.
3.3 Downstream chain#
Tasks with status=pending are picked up by the task-poller, which calls multilevel-video-understanding to obtain a summary_text, then runs rule-engine to decide whether to insert into alerts and push to subscribers.
4. type=static#
A stable segment with no motion. Clients typically emit a static event to “close out” a quiet period after a motion clip.
4.1 Payload fields#
Field |
Type |
Required |
Description |
|---|---|---|---|
|
|
✅ |
Static-period start. Written to |
|
|
Optional |
Static-period end. Written to |
|
|
✅ |
Static duration in seconds. Written to |
Required fields: start_time, duration_seconds
4.2 MCP-side handling#
INSERT INTO events(motion_type=static) — only this one table.Does not create a
video_summary_tasksrow (no video to summarize).Does not trigger rule-engine.
5. type=recording#
A continuous recording segment (distinct from motion: not tied to motion detection, sliced on a fixed rolling cadence).
5.1 Payload fields#
Field |
Type |
Required |
Description |
|---|---|---|---|
|
|
✅ |
Absolute path of the recorded MP4. Written to |
|
|
✅ |
Recording start. Written to |
|
|
✅ |
Recording end. Written to |
|
|
Optional |
Recording duration. Written to |
|
|
Optional |
Recording file size in bytes. Written to |
Required fields: recording_path, recording_start, recording_end
5.2 MCP-side handling#
INSERT INTO recordings— only this one table.Does not write
events, does not writevideo_summary_tasks.Does not trigger rule-engine.
The MCP server’s periodic cleanup job deletes expired
<data_dir>/recordings/<YYYY-MM-DD>/directories according tostorage.retention_days.
Use: long-window video retrieval.
scene_queryand similar tools fall back to recordings when looking up a time window (durations are stable), while motion clips drive event-focused queries.motionandrecordingare independent streams — continuous recording rolls on its own cadence, motion detection slices on its own; the two do not interfere.
6. Five canonical examples#
The five examples below are paired as “what the client sends → what the MCP server does”. All use sourceId=cam_child for illustration. The production sender is videostream-analytics, but identical requests from any HTTP client produce identical server-side behavior.
6.1 motion w/o prefilter (this monitor has no NPU prefilter configured)#
Request POST /events:
{
"sourceId": "cam_child",
"type": "motion",
"timestamp": "2026-06-25T14:30:45",
"payload": {
"event_file_path": "/data/cam_child/motion_events/2026-06-25/seg_00001.mp4",
"summary_clip_input": "/data/cam_child/motion_events/2026-06-25/seg_00001.mp4",
"start_time": "2026-06-25T14:30:30",
"end_time": "2026-06-25T14:30:45",
"duration_seconds": 15.2
}
}
MCP-side handling:
Insert into
events:INSERT INTO events (monitor_id, motion_type, start_time, end_time, duration_seconds, event_file_path, prefilter_passed, prefilter_classes, prefilter_confidence, trajectory_region) VALUES ('cam_child', 'motion', '2026-06-25T14:30:30', '2026-06-25T14:30:45', 15.2, '/data/cam_child/motion_events/2026-06-25/seg_00001.mp4', NULL, NULL, NULL, NULL);
Insert into
video_summary_taskswithstatus='pending'(prefilter field absent ⇒ no prefilter configured):INSERT INTO video_summary_tasks (monitor_id, event_id, summary_clip_input, status) VALUES ('cam_child', <ev.id>, '/data/cam_child/motion_events/2026-06-25/seg_00001.mp4', 'pending');
Return
200 {"status":"ok","event_id":<ev.id>,"task_id":<task.id>}.On its next tick, task-poller picks up this task → calls the Video Summary Service → writes
summary_text→ forwards to rule-engine.
6.2 motion with prefilter pass (NPU detected a target class)#
Request POST /events:
{
"sourceId": "cam_child",
"type": "motion",
"timestamp": "2026-06-25T14:30:45",
"payload": {
"event_file_path": "/data/cam_child/motion_events/2026-06-25/seg_00002.mp4",
"summary_clip_input": "/data/cam_child/motion_events/2026-06-25/seg_00002_input.mp4",
"start_time": "2026-06-25T14:30:30",
"end_time": "2026-06-25T14:30:45",
"duration_seconds": 15.2,
"prefilter_passed": 1,
"prefilter_classes": "[\"person\"]",
"prefilter_confidence": 0.92
}
}
MCP-side handling:
Insert into
events, all three prefilter columns populated (prefilter_passed=1,prefilter_classes='["person"]',prefilter_confidence=0.92).Insert into
video_summary_taskswithstatus='pending'(prefilter passed → forward to Video Summary Service).Note:
summary_clip_inputpoints to the bbox-cropped_input.mp4produced upstream — the Video Summary Service sees the cropped clip, not the full frame, saving tokens.Return
200 {"status":"ok","event_id":<ev.id>,"task_id":<task.id>}.Task-poller picks up the task → Video Summary Service → rule-engine → may insert into
alerts.
6.3 motion with prefilter NOT passed (NPU saw no target class)#
Request POST /events:
{
"sourceId": "cam_child",
"type": "motion",
"timestamp": "2026-06-25T14:31:10",
"payload": {
"event_file_path": "/data/cam_child/motion_events/2026-06-25/seg_00003.mp4",
"summary_clip_input": "/data/cam_child/motion_events/2026-06-25/seg_00003.mp4",
"start_time": "2026-06-25T14:30:55",
"end_time": "2026-06-25T14:31:10",
"duration_seconds": 15.0,
"prefilter_passed": 0,
"prefilter_classes": "[]",
"prefilter_confidence": 0.0
}
}
MCP-side handling:
Insert into
eventswithprefilter_passed=0— kept for audit (useful for later analysis of prefilter accuracy).Insert into
video_summary_taskswithstatus='ignored':INSERT INTO video_summary_tasks (monitor_id, event_id, summary_clip_input, status) VALUES ('cam_child', <ev.id>, '/data/cam_child/motion_events/2026-06-25/seg_00003.mp4', 'ignored');
Task-poller’s
getPendingTasksonly selects rows withstatus='pending'. This task is never polled, so the Video Summary Service is never called.Rule-engine is not triggered, no alert is generated.
Return
200 {"status":"ok","event_id":<ev.id>,"task_id":<task.id>}— the row was inserted, just in a terminal state.
Note: This is the core value of prefilter: a cheap NPU YOLO drops “leaves moving / lighting change / wandering pets” false-motion clips before they reach the Video Summary Service.
6.4 static#
Request POST /events:
{
"sourceId": "cam_child",
"type": "static",
"timestamp": "2026-06-25T14:31:25",
"payload": {
"start_time": "2026-06-25T14:31:10",
"end_time": "2026-06-25T14:31:25",
"duration_seconds": 15.0
}
}
MCP-side handling:
Insert into
events:INSERT INTO events (monitor_id, motion_type, start_time, end_time, duration_seconds, event_file_path, prefilter_passed, prefilter_classes, prefilter_confidence, trajectory_region) VALUES ('cam_child', 'static', '2026-06-25T14:31:10', '2026-06-25T14:31:25', 15.0, NULL, NULL, NULL, NULL, NULL);
Does not create a
video_summary_tasksrow.Does not trigger rule-engine.
Returns
200 {"status":"ok","event_id":<ev.id>}(notask_idbecause no task row was created).
Note:
state_queryreadseventsand sees the alternatingmotion → static → motion → static …sequence to infer “active / idle” timing of the room; elder-wakeup’s “still in bed” detection depends on longstaticruns.
6.5 recording#
Request POST /events:
{
"sourceId": "cam_child",
"type": "recording",
"timestamp": "2026-06-25T14:31:00",
"payload": {
"recording_path": "/data/cam_child/recordings/2026-06-25/rec_20260625_143000.mp4",
"recording_start": "2026-06-25T14:30:00",
"recording_end": "2026-06-25T14:31:00",
"duration_seconds": 60.0,
"file_size_bytes": 8192000
}
}
MCP-side handling:
Insert into
recordings:INSERT INTO recordings (monitor_id, file_path, start_time, end_time, duration_seconds, file_size_bytes) VALUES ('cam_child', '/data/cam_child/recordings/2026-06-25/rec_20260625_143000.mp4', '2026-06-25T14:30:00', '2026-06-25T14:31:00', 60.0, 8192000);
Does not write
events, does not writevideo_summary_tasks.Does not trigger rule-engine.
Returns
200 {"status":"ok","recording_id":<rec.id>}.The MCP server’s periodic cleanup job deletes expired
<data_dir>/recordings/<YYYY-MM-DD>/directories according tostorage.retention_days.
Note:
recordingandmotionare two independent streams: continuous recording rolls on its own cadence; motion detection slices on its own. They do not interact.scene_query, etc., prefer recording segments for time-window playback (stable durations) and use motion clips for event-focused queries.
6.6 Error-path examples#
These illustrate the 4xx / 5xx responses a client should be prepared to handle. Same POST /events URL.
Missing required fields → 422 Unprocessable Entity (semantic error, do not retry):
Request:
{
"sourceId": "cam_child",
"type": "motion",
"timestamp": "2026-06-25T14:30:45",
"payload": {
"event_file_path": "/data/cam_child/motion_events/2026-06-25/seg_00004.mp4",
"summary_clip_input": "/data/cam_child/motion_events/2026-06-25/seg_00004.mp4"
}
}
Response:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{"error":"missing required fields","code":"missing_required_fields","missing":["start_time","duration_seconds"]}
Unknown type → 422 Unprocessable Entity (semantic error, do not retry):
Request:
{ "sourceId": "cam_child", "type": "audio", "timestamp": "2026-06-25T14:30:45", "payload": {} }
Response:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{"error":"unknown event type","code":"unknown_event_type","type":"audio"}
Malformed JSON → 400 Bad Request (framing error, fix client):
Request body: not even { json
Response:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"error":"invalid JSON: Unexpected token n in JSON at position 0","code":"invalid_json"}
Bad envelope shape → 400 Bad Request (framing error, fix client):
Request:
{ "sourceId": 12345, "type": "motion", "payload": {} }
Response:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"error":"envelope.sourceId must be a non-empty string","code":"invalid_envelope"}
Body too large → 413 Payload Too Large:
HTTP/1.1 413 Payload Too Large
Content-Type: application/json
{"error":"payload too large","code":"body_too_large","limit_bytes":1048576}
Wrong Content-Type → 415 Unsupported Media Type:
HTTP/1.1 415 Unsupported Media Type
Content-Type: application/json
{"error":"content-type must be application/json","code":"unsupported_media_type"}
DB write threw → 500 Internal Server Error (retry after backoff):
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{"error":"SQLITE_BUSY: database is locked","code":"internal_error"}
7. DB-write quick reference#
Event type |
|
|
|
Triggers rule-engine |
|---|---|---|---|---|
|
✅ motion |
✅ |
— |
✅ |
|
✅ motion |
✅ |
— |
✅ |
|
✅ motion |
✅ |
— |
❌ |
|
✅ static |
— |
— |
❌ |
|
— |
— |
✅ |
❌ |