REST API

Reference for the FastAPI endpoints exposed by test_server.py on port 8083. All endpoints accept and return JSON unless noted.

Auth model. In the multi-stand deployment every request enters through the gateway (gateway_service/app.py) — the only publicly exposed port. Every endpoint except the gateway allowlist — /, /health, /api/auth/login, /api/auth/logout, and /novnc/* — requires a bearer session token (header Authorization: Bearer <token>) obtained from POST /api/auth/login. The gateway authenticates the caller, forwards the identity to the per-stand worker (test_server.py), and proxies the worker endpoints documented below. See the dedicated Gateway section, Environments & the Gateway and Accounts, Roles & Audit.

Health

GET /health{"status":"ok"}

Gateway

These endpoints are served by the gateway itself (gateway_service/app.py), not proxied to a worker. They handle login, central account / stand / env-profile management, and run dispatch onto a worker pool. Cross-ref: Environments & the Gateway and Accounts, Roles & Audit.

Auth

Method Path Purpose

POST

/api/auth/login

Authenticate. Body {username, password}. Returns {token, user:{username, role, stand}}. Allowlisted (no token required); rate-limited (5 fails / 60 s per user+IP → 429).

POST

/api/auth/logout

Invalidate the caller’s session token. Allowlisted.

GET

/api/auth/me

Current session identity {username, role, stand, …}. Requires a token.

Users (admin)

Method Path Purpose

GET

/api/users

List all accounts.

POST

/api/users

Create an account. Body {username, password, role?, stand?} (role defaults to editor).

POST

/api/users/{username}/reset-password

Set a new password. Body {password}.

DELETE

/api/users/{username}

Delete an account.

Audit (admin)

Method Path Purpose

GET

/api/activity

Central activity / audit log. Query: username?, action?, limit (default 200), offset.

Env Profiles

Profiles are named bundles of worker settings (emulator_url, ACS URL, …) that the gateway materialises onto a worker at dispatch time.

Method Path Purpose

GET

/api/gateway/profiles

List env profiles (any authenticated user).

POST

/api/gateway/profiles

Create / replace a profile. Body {name, settings:{}} (admin). May auto-provision an emulator-MCP wrapper for new envs.

GET

/api/gateway/profiles/{name}

Full profile settings (admin).

DELETE

/api/gateway/profiles/{name}

Delete a profile. ?teardown=true also best-effort removes the provisioned mcp-tr-emul-<name> wrapper stack (admin).

Stands / Workers (admin)

Method Path Purpose

GET

/api/gateway/stands

List worker stands.

POST

/api/gateway/stands

Register / upsert a stand. Body {name, base_url, display?, novnc_url?, enabled?}.

PATCH

/api/gateway/stands/{name}

Update a stand (e.g. {enabled} to toggle).

DELETE

/api/gateway/stands/{name}

Deregister a stand. ?teardown=true also best-effort removes the provisioned tr-worker-<name> stack.

POST

/api/gateway/workers

Auto-provision a new worker stack via Portainer. Body {name?} (auto-named w<N> when omitted). 503 when provisioning is not configured.

Run Dispatch

Method Path Purpose

POST

/api/gateway/run

Materialise env’s settings onto a free worker, then start a single run there. Body `{env, test_file, model?, dry_run?}. 409 when that env’s emulator is busy, 503 when all workers are busy.

POST

/api/gateway/run/batch

Same, for a batch. Body {env, test_files:[], order?, model?, dry_run?}.

GET

/api/live/stand

Name of the worker running the caller’s own active run (so "Live Browser" connects to the right noVNC). Returns `{stand: name

The gateway proxies the worker endpoints in the sections below per-caller and owner-scopes them (see Runs). Batch-status, per-run reads, WebSocket and stop are fanned out across the worker pool so they reach the caller’s run on whichever worker it landed.

Test Cases

Method Path Purpose

GET

/api/tests

List all YAML cases. Returns [{file, id, title, portal, base_url, step_count, steps_preview}].

GET

/api/tests/{filename}

Parsed JSON view of a single case.

GET

/api/tests/{filename}/yaml

Raw YAML text of a case.

POST

/api/tests

Create a new case from a JSON body.

PUT

/api/tests/{filename}

Replace an existing case.

POST

/api/tests/{filename}/regenerate

Regenerate emulator_setup from current text.

POST

/api/tests/upload-yaml

Upload a hand-edited YAML file.

DELETE

/api/tests/{filename}

Delete a case.

DELETE

/api/tests

Delete all cases (reset volume).

GET

/api/sections

Distinct values of section (for filters).

Case Organization

Projects and per-case org fields (project, references, priority, type) used to group and filter cases in the editor. See Organizing Cases: Projects & Sections for the user-facing workflow.

Method Path Purpose

GET

/api/projects

List projects, each with a case_count (any authenticated user).

POST

/api/projects

Create a project. Body {name, description?} (admin / editor).

PATCH

/api/projects/{name}

Rename ({new_name}) and/or edit description; rewrites the project field of every affected case (admin / editor).

DELETE

/api/projects/{name}

Delete a project; its cases are orphaned to Unassigned (project="") (admin / editor).

POST

/api/tests/assign-project

Bulk-assign cases to a project. Body {project, files:[]}. Returns {assigned} (admin / editor).

GET

/api/ui-config

Read-only, non-secret UI config (currently {jira_base_url}) for rendering reference links.

POST

/api/testrail/backfill-fields

Backfill org fields (project, references, priority, type) from TestRail into local YAMLs — no conversion / Stage 2 / cache bump. Body {project_id, suite_id, section_id?, dry_run?, ref_prefix?}; dry_run defaults to true (admin / editor).

Trees

XML tree files live under /opt/mcp-shared/ on the test-runner host and are referenced from each YAML’s emulator_setup.tree_file. The editor’s Tree File dropdown is populated from these endpoints.

Method Path Purpose

GET

/api/trees

List *.xml files in /opt/mcp-shared/. Returns [{name, size, mtime}] (mtime as Unix epoch seconds).

POST

/api/trees/upload

Upload an .xml tree (multipart file=@path/to/tree.xml). Server validates the payload via ET.fromstring, sanitises the basename (rejects path-traversal, slashes, dots), and writes it into /opt/mcp-shared/. Returns {file, size, replaced}replaced=true means an existing file with the same name was overwritten.

DELETE

/api/trees/{filename}

Delete an .xml tree from /opt/mcp-shared/. Filename is reduced to its basename and required to end with .xml; 400 on invalid input, 404 if the file is missing.

# List
curl -s http://ai.friendly-tech.com:8083/api/trees | python -m json.tool

# Upload (or replace)
curl -F "file=@./181.xml" http://ai.friendly-tech.com:8083/api/trees/upload
Uploads are accepted only for .xml. Path traversal attempts (../, \\, /) and dot-aliases (., ..) are rejected with HTTP 400.

Runs

Method Path Purpose

POST

/api/run

Start a single run. Query parameters: test_file (required), model (default opus), dry_run (default false). Returns {run_id}.

POST

/api/run/stop

Force-stop the caller’s own run (owner-scoped, no body). Not "all runs" — through the gateway it fans out to every worker so it reaches the caller’s run wherever it landed.

POST

/api/run/batch

Start a batch. Body: {test_files:[], model?, dry_run?} (defaults: model=opus, dry_run=false). Returns {batch_id, test_files, total}.

GET

/api/run/batch

Current batch status. Returns {status, id?, total?, current_index?, run_ids?, test_files?, model?} (status is running / idle / done / none).

GET

/api/run/batch/report

Latest in-memory batch report JSON.

GET

/api/run/batch/reports

List archived batch reports as [{batch_id, status, model, started_at, finished_at, total_elapsed_ms, summary:{total,passed,failed,errors}}].

GET

/api/run/batch/reports/{batch_id}

Full archived batch report (summary + per-test results).

DELETE

/api/run/batch/reports

Wipe archived reports on disk.

GET

/api/runs

Last 100 run summaries (in-memory).

GET

/api/runs/active

List of run-ids currently in running state. Used by the UI to auto-reattach to a live run after refresh.

GET

/api/runs/{run_id}

Full report for one run.

DELETE

/api/runs

Clear in-memory run registry.

Owner isolation. Run reads, batch reports, WebSocket attach and stop are owner-scoped — a run is visible only to the account that started it (_run_visible_to). The one admin exemption is GET /api/runs?all=true, which lets an admin list every account’s completed runs; all other run / report / WS / stop endpoints stay strictly owner-scoped even for admins. Consequently POST /api/run/stop stops only the caller’s own run, not every running test.

POST /api/run uses query parameters, not a JSON body. Curl example:
curl -X POST "http://ai.friendly-tech.com:8083/api/run?test_file=C7677.yaml&model=opus"

GET /api/runs/{run_id} is the canonical poll endpoint — returns:

{
  "run_id":"...",
  "test_id":"C7677",
  "title":"...",
  "status":"passed|failed|error|running",
  "elapsed_ms":42031,
  "phase":"loading|pages|executing|parsing|done",
  "steps":[{"step":1,"status":"pass","description":"..."}],
  "report":{"status":"passed", "steps":[...]},
  "output":"...",
  "mcpLog":[...]
}

WebSocket

WS /ws/run/{run_id} — streams live updates for a run (steps, output append, MCP log). Same payload shape as GET /api/runs/{run_id} but pushed.

TestRail Integration

Method Path Purpose

GET

/api/testrail/status

Connection check.

GET

/api/testrail/projects

List projects.

GET

/api/testrail/suites

Query: ?project_id=N.

GET

/api/testrail/sections

Query: ?project_id=N&suite_id=M.

POST

/api/testrail/preview

Preview converter output without writing.

POST

/api/testrail/import

Batch import. Body: {case_ids:[…​], model?, use_knowledge?, force_refresh?, overwrite?, assign_template_trees?}.

POST

/api/testrail/reimport

Single reimport. Body: {case_id, use_knowledge?, force_refresh?}.

POST

/api/testrail/sync

Trigger the auto-sync loop programmatically. Periodic refresh is driven by the TESTRAIL_SYNC_INTERVAL env / Settings → Auto-sync Interval.

XML Import (API only — no UI)

The XML paste / batch modals were removed from the Editor; the converter is still callable over HTTP for air-gapped or read-only TestRail accounts. See Importing Tests → XML Import API.

Method Path Purpose

POST

/api/parse-xml

Convert a single <case> XML to YAML preview. Body: {xml}.

POST

/api/import-xml-batch

Convert and persist a full XML export. Body: {xml, emulator_serial?, overwrite?, llm_model?, backend_rag?, section_filter?} (server defaults: overwrite=true, llm_model="opus", backend_rag=false).

POST

/api/run-xml

Convert + run a <case> in one shot. Query parameters: model (default opus), dry_run (default false). Body: {xml}.

Tools

Method Path Purpose

POST

/api/generate-tree

NL → TR-181 tree via bbf-tree-mcp. Body: {case_id, description?}. Returns {success: bool, tree_file?: str, error?: str}.

POST

/api/simulate-score

Run score_simulator.py against a parameter set. Body: {parameters:{}, kpi_type, expected_scores?}. Returns {computed, user_experience, match, details, warnings}.

GET

/api/mcp-status

Probe each MCP server. Returns {mcps: {<name>: {ok: bool, …​}}, connected: bool}.

GET

/api/mcp-diag

Detailed MCP diagnostic dump (per-server URLs, last error, last successful tool list).

Settings

Method Path Purpose

GET

/api/settings

Current persisted settings.

PUT

/api/settings

Replace settings. Body: full settings JSON.

DELETE

/api/settings

Wipe UI overrides; effective values fall back to docker-compose env vars and then to hardcoded defaults.

Admin / Ops

Ops endpoints used by the test-runner UI and by the host-side credential refresh script. They lean on Portainer (PORTAINER_URL / PORTAINER_USERNAME / PORTAINER_PASSWORD env vars) and return 503 when that wiring is missing.

Method Path Purpose

POST

/api/admin/restart-mcp-emul[?force=true]

Restart the sibling mcp-tr-emul container via Portainer. Refuses while runs are active unless force=true. Returns {restarted, active_runs_at_request, forced, …​}. 502 on Portainer error, 409 on active runs without force.

GET

/api/admin/restart-mcp-emul/available

Lightweight probe so the UI can hide the Restart button when Portainer env is unset. Returns {available: bool, endpoint_id}.

GET

/api/admin/credentials-status

Inspect on-disk Claude CLI credentials. Returns {present, valid_json, size_bytes, expiresAt, now_ms, delta_h, expired, subscriptionType, scopes} (no token bytes leaked).

POST

/api/admin/refresh-credentials

Push fresh ~/.claude/.credentials.json into the container. Auth via X-Refresh-Token header (shared secret = PORTAINER_PASSWORD). Body is the raw credentials JSON. Written atomically (tmp + rename + fsync). Called every 4 h by test_runner/refresh-credentials.ps1 on the host.

POST

/api/admin/sync-stack-env[?force=true]

Push the current settings.json values that map to compose env substitutions (TR069_EMULATOR_URL, TR069_ACS_URL, …) into the Portainer stack’s Env array and trigger a redeploy. Refuses on active runs unless force=true. 502 on Portainer GET/PUT error.

QA Validation Notes

Source-of-truth YAMLs at /data/qa-notes/<id>.yaml on the qa-notes volume. Each save fires a webhook to knowledge-mcp/admin/refresh-notes that re-pulls and re-embeds. See QA Validation Notes for the user-facing workflow.

Method Path Purpose

GET

/api/notes

List notes with filters. Query params: status (default active, pass all to disable the filter), severity, scope_widget, scope_kpi, scope_test_id, scope_topology, tag, q (substring over title+body), include_deprecated (bool). Returns [{id, title, snippet, severity, status, scope, tags, version, updated_at}] sorted newest first.

GET

/api/notes/{id}

One full note (body untruncated, all auto-fields populated).

POST

/api/notes

Create. Body shape mirrors the YAML schema: {title, body, scope:{test_ids, widgets, kpi_types, topologies, global}, severity, status, tags, id?, created_by?}. id is auto-derived from title when omitted (slug + collision suffix). Validation: title/body required, at least one scope field non-empty OR scope.global=true. Returns the created note with HTTP 201.

PUT

/api/notes/{id}

Full replace. Same body as POST plus an optional expected_version int for optimistic locking — server returns 409 with {current_version, expected_version} if the version moved under you. Bumps version on success.

DELETE

/api/notes/{id}

Soft-deprecate by default (file stays on disk with status=deprecated). Pass ?hard=true to physically remove the YAML.

POST

/api/notes/reindex

Manual webhook trigger. Returns {triggered: bool, detail: str, knowledge_summary?: {added, updated, unchanged, deleted, errors, fetched, status}}. Day-to-day not needed — each save fires the webhook automatically.

GET

/api/notes/raw

All notes (incl. deprecated) in the on-disk YAML shape. Used by knowledge-mcp as the pull endpoint when refreshing the Qdrant index.

GET

/api/notes/_meta/options

Allowlists for UI dropdowns: {severity, status, kpi_types, topologies}.

scope.global is serialised as the YAML key global (a Python keyword). The Pydantic model exposes it as is_global internally but the REST contract uses the plain global alias both inbound and outbound. The same shape is what /api/notes/raw returns to knowledge-mcp.
# Create a note targeting one case
curl -X POST http://ai.friendly-tech.com:8083/api/notes \
  -H 'Content-Type: application/json' \
  -d '{
    "title":"Monitoring Health flap 70/80",
    "body":"ft-device-network DeviceParameterStorageService is incremental-only...",
    "scope":{"test_ids":["C7626"],"widgets":["Monitoring"],"kpi_types":["monitoring"],"topologies":[],"global":false},
    "severity":"warning",
    "status":"active",
    "tags":["flap","clickhouse"]
  }'

# Search via knowledge-mcp (scope-filtered)
curl -X POST http://ai.friendly-tech.com:7030/search/qa_notes \
  -H 'Content-Type: application/json' \
  -d '{"query":"Health flap","test_id":"C7626","limit":3}'

# Manual reindex
curl -X POST http://ai.friendly-tech.com:8083/api/notes/reindex