mspbots-agent-mcp
Officialby MSPbotsAI
README.md
# mspbots-agent-mcp
MCP server for the **MSPbots Agent Platform API** — exposes the Agent Platform's
connector inventory, agent triggers (scheduled/event tasks), and per-agent policy
(permissions, self-evaluation, human-in-the-loop approval) to MCP clients.
It follows the same design as the sibling `ticketqa-mcp` service: stateless, no
stored credentials, per-request header authentication over the
[Model Context Protocol](https://modelcontextprotocol.io/) (Streamable HTTP/SSE
transport).
## When would you use this
This MCP is meta: it doesn't wrap a customer-facing integration, it wraps the
Agent Platform's own admin/config API for an agent. The "users" are platform
admins/builders setting up an agent, or an agent introspecting/adjusting its
own configuration:
- "What connectors does this tenant have hooked up, and which are actually
connected right now?" → `mspbotsagent_get_connectors`
- "What connectors can *this agent* use / what does it pull data from?" →
`mspbotsagent_get_sop_data_sources` (agent-scoped — not
`mspbotsagent_get_connectors`, which is tenant-wide)
- "Make this agent run every Monday morning to summarize open tickets" →
`mspbotsagent_upsert_trigger` (type `recurring`)
- "Kick this agent off automatically whenever a new ConnectWise ticket comes
in" → `mspbotsagent_upsert_trigger` (type `event`, after checking
`mspbotsagent_get_trigger_catalog`)
- "Lock down what this agent is allowed to do without asking first" →
`mspbotsagent_upsert_agent_permissions`
- "Require a human to sign off before this agent issues a refund" →
`mspbotsagent_upsert_agent_approval`
- "Write up this agent's SOP so it has a documented, repeatable procedure" →
`mspbotsagent_set_sop_purpose` / `mspbotsagent_set_sop_procedure` / the
other `mspbotsagent_set_sop_*` tools
- "What SOPs does this tenant have / which are still drafts?" →
`mspbotsagent_list_sops`
- "Ask the onboarding SOP what it would do with this ticket" →
`mspbotsagent_chat_with_sop` (one blocking turn with that SOP's own agent)
## Tools
Every tool takes its credentials from the request headers
(`X-MSP-Token` / `X-MSP-Tenant-Id` / `X-MSP-Host`) — no token is ever passed as a
tool argument.
### Connectors
| Tool | What it does | Parameters |
|---|---|---|
| `mspbotsagent_get_connectors` | List every connector on the current tenant, returning each one's name, whether it is installed, and its connection status | none |
`mspbotsagent_get_connectors` is **tenant-scoped**: it takes no `agent_id` and
returns the same inventory regardless of which agent is being configured. For
the connectors a *specific* agent is declared to use, see
[`mspbotsagent_get_sop_data_sources`](#agent-sop-author) instead.
`mspbotsagent_get_connectors` returns one row per connector:
| Field | Description |
|---|---|
| `name` | Connector display name |
| `integration` | Connector/integration key (e.g. `connectwise-command`) |
| `scope` | Connector scope (e.g. `mspbots`) |
| `managed` | How it is managed (e.g. `gateway`) |
| `installed` | Whether it is installed/enabled (bool, from the API's `enabled`) |
| `connected` | Whether it is currently connected (bool, from the API's `connected`) |
| `status` | Derived status: `not_installed` / `connected` / `installed_disconnected` |
> Every connector the API returns also carries a very large base64 `logo` field —
> this service **strips it** to keep responses lean.
Backing endpoint: `GET /apps/mb-platform-agent/api/capabilities/connectors`, which
returns `{"success": true, "data": {"list": [ {connector}, ... ]}}`.
### Triggers (agent scheduled / event tasks)
A trigger runs an agent automatically: on a cron schedule (`recurring`) or when an
event fires in an external integration (`event`). Its primary key is `taskId`, and it
belongs to one `agentId`.
| Tool | What it does | Parameters |
|---|---|---|
| `mspbotsagent_list_triggers` | List all of an agent's triggers (paginated) | `agent_id` (required), `page` (default 1), `page_size` (default 50) |
| `mspbotsagent_upsert_trigger` | Create or modify a trigger (with `task_id` = modify, without = create) | `agent_id`, `task_id`, `name`, `prompt`, `type` (`recurring`/`event`), `enabled`, `expires_in_days`; recurring: `schedule` (cron, 1h minimum), `timezone`, `run`; event: `trigger_integration`, `trigger_events` |
| `mspbotsagent_delete_trigger` | Delete a trigger (cannot be undone) | `task_id` (required) |
| `mspbotsagent_get_trigger_catalog` | List the integration + events combinations that are valid for event triggers | none |
| `mspbotsagent_run_trigger` | Run one trigger manually, right now (for testing or a catch-up run) | `task_id` (required) |
> Creating via `upsert` requires `agent_id`/`name`/`prompt`/`type`; `recurring` also
> requires `schedule` (cron, 1h minimum interval), and `event` requires
> `trigger_integration` + `trigger_events`, whose combination must appear in the
> `get_trigger_catalog` catalog. When modifying, submit only the fields you want to
> change.
>
> Backing endpoints: `GET|POST /api/tasks`, `PUT|DELETE /api/tasks/:taskId`,
> `GET /api/tasks/trigger-catalog`, `POST /api/tasks/:taskId/run`.
### Agent policy (permissions / evaluation / approval)
All three blocks of configuration hang off the **same agent record**: the reads share
one GET, and the writes share one **partial** PUT. So **do not write to the same agent
concurrently** (partial patches overwrite each other); and when a read comes back with
`policyError=true`, the upsert tools **refuse to write `permission`/`interruptOn` back**
(owners-only updates are unaffected) to avoid persisting a broken policy. The primary
key is `agentId` throughout.
| Tool | What it does | Parameters |
|---|---|---|
| `mspbotsagent_get_agent_permissions` | Read the permission config: `permission` / `interruptOn` / `owners` / `tools` (read-only) / `policyError` | `agent_id` (required) |
| `mspbotsagent_upsert_agent_permissions` | Update tool permissions, interrupt settings and ownership (partial; the three keys are independent, so sending owners alone is valid) | `agent_id` (required), `permission` (tool→`allow`/`ask`/`deny`), `interrupt_on` (tool→`true` or `{allowed_decisions, description}`), `owners` (`[{userId, name, email}]`) — at least one of the three |
| `mspbotsagent_get_agent_evaluation` | Read the self-evaluation config `review = {rules, max_iterations}` | `agent_id` (required) |
| `mspbotsagent_upsert_agent_evaluation` | Set/update the self-evaluation rules (an empty `rules` array turns self-evaluation off) | `agent_id` (required), `rules` (rule array), `max_iterations` |
| `mspbotsagent_get_agent_approval` | Read the human approval rules `approval` (array) | `agent_id` (required) |
| `mspbotsagent_upsert_agent_approval` | Set/update the human approval rules (an empty array removes every approval gate) | `agent_id` (required), `rules` (approval rule array) |
Rule object shapes:
- evaluation `rules[]`: `{ rubric, name, description, triggers: [regex/keywords] }`
- approval `rules[]`: `{ name, intent, triggers: [regex/keywords], tools: [...], decisions: [...] }`
> Backing endpoints: `GET /api/agents/:id` (shared by all three getters),
> `PUT /api/agents/:id` (shared by all three upserts, partial: writing
> `permission`/`interruptOn`, `review`, and `approval` respectively).
### Agent SOP author
An agent's SOP (standard operating procedure) draft has 5 independent fields, each with
its own read and write tool, and each field writes through its own separate endpoint
(unlike permission/evaluation/approval above, which share a single
`PUT /api/agents/:id`). Every write has the same shape: `PUT {"value": ...}`. The
primary key is `agentId`.
> Even though each field has a different endpoint, all 5 still live on the **same agent
> record** (the `sopAuthor` sub-document), so the rule from
> permission/evaluation/approval applies here too: **do not write to the same agent's
> SOP fields concurrently**, or they may overwrite each other. The SOP itself is a
> document — a planning draft describing what the agent "should do"; the
> permission/evaluation/approval config above is the behavior policy that actually takes
> effect at runtime, deciding what the agent "can actually do". The two are
> complementary but distinct kinds of configuration.
| Tool | What it does | Parameters |
|---|---|---|
| `mspbotsagent_get_sop_name` | Read the SOP name | `agent_id` (required) |
| `mspbotsagent_set_sop_name` | Set the SOP name (non-empty, ≤60 chars, unique within the tenant, cannot be cleared) | `agent_id` (required), `value` (required) |
| `mspbotsagent_get_sop_source` | Read the source (the original task description the SOP was written from) | `agent_id` (required) |
| `mspbotsagent_set_sop_source` | Set the source (pass null to clear it) | `agent_id` (required), `value` (required, string or null) |
| `mspbotsagent_get_sop_purpose` | Read the purpose (markdown) | `agent_id` (required) |
| `mspbotsagent_set_sop_purpose` | Set the purpose (markdown) | `agent_id` (required), `value` (required) |
| `mspbotsagent_get_sop_data_sources` | Read the dataSources list (structured object) | `agent_id` (required) |
| `mspbotsagent_set_sop_data_sources` | Set the dataSources list (structured object) | `agent_id` (required), `value` (required, object) |
| `mspbotsagent_get_sop_procedure` | Read the procedure (markdown) | `agent_id` (required) |
| `mspbotsagent_set_sop_procedure` | Set the procedure (markdown) | `agent_id` (required), `value` (required) |
The dataSources `value` shape (each source stores only `integration`, no
`preconditions`):
```json
{
"sources": [
{ "integration": "open-meteo" },
{ "integration": "ms-graph" }
]
}
```
Every entry `mspbotsagent_get_sop_data_sources` reads back is a connector (an MCP
server available to the agent); besides `integration` it carries connection status and
a tool list:
| Field | Type | Meaning |
|---|---|---|
| `integration` | string | Data source key |
| `org` | bool, optional | Present only when the source was added at the organization level |
| `found` | bool | `false` = the referenced connector has been deleted or disabled and is unusable; treat it as non-existent |
| `name` / `description` / `transport` / `endpoint` | string | Connector display info and its MCP endpoint (present only when `found`) |
| `connection` | string | Current connection status, see the table below. **Agent level wins over org level** |
| `tenantConnected` | bool | Whether an organization/tenant-level connection exists |
| `enabled` | bool | Whether the connector is enabled at the organization level |
| `managed` | string, optional | `"gateway"` for gateway-managed connectors |
| `tools` | array | The tools this connector provides (`{name, label, description}`); empty when not connected or when discovery failed |
`connection` values:
| Value | Meaning | How to treat it |
|---|---|---|
| `"agent"` | This agent connected with its own account (agent-level connection) | Usable |
| `"org"` | It is using the organization/tenant-level connection | Usable |
| `"none"` | Not connected (or the connector is disabled for the organization) | Not usable; the user has to connect it first |
| `"unavailable"` | The gateway status could not be read | ⚠️ **Does not mean disconnected** — it just could not be read at this moment. Do not call it "not connected" on this basis |
Two easy mistakes, both written into the tool descriptions:
- `connection` is a single value and agent wins. **When both levels are connected,
`connection` shows only `"agent"`**, and the tenant half is only visible in
`tenantConnected`. There is currently no symmetric standalone `agentConnected`
boolean, so agent level can only be detected via `connection === "agent"`.
- `"none"` and `"unavailable"` must be handled differently: the former really is not
connected (fine to prompt the user to go connect it), while the latter merely means
the status could not be read (lean towards still-usable, and do not misreport it).
Summary test: usable = `found === true` and `connection ∈ {agent, org}` (with
`unavailable` leaning usable); unusable = `found === false` or `connection === "none"`.
> `mspbotsagent_get_sop_data_sources` is the **agent-scoped** answer to
> "what connectors does this agent use", and its rows carry connection
> status, so it usually answers "can this agent use X" on its own.
> [`mspbotsagent_get_connectors`](#connectors) is the tenant-wide inventory
> — reach for it when the question is not about a particular agent.
> Backing endpoints: `GET|PUT /api/agents/:id/sop-author/{name,source,purpose,data-sources-list,procedure}`.
### Agent skills
> 🚫 **Currently not exposed.** These tools are commented out in
> `create_mcp_server()` (`src/mspbots_agent_mcp/server.py`), so they do not appear in
> `tools/list` and cannot be called by external clients. The implementation stays in
> `src/mspbots_agent_mcp/tools/skills.py`; re-enable by uncommenting the `skills`
> import and `skills.register(...)` call there (and the matching entries in
> `tests/test_tools.py`). The rest of this section describes them as implemented.
The skills available to an agent come in three kinds: `mspbots` (built into the
platform), `org` (shared across the organization), and `agent` (private to that agent).
Only private (`agent`) skills can be created/edited/deleted here; `mspbots`/`org`
skills can only be read, not changed. The primary key is `capabilityId` (the `id` on
each record from the list endpoint).
| Tool | What it does | Parameters |
|---|---|---|
| `mspbotsagent_list_agent_skills` | List every skill available to an agent (org-shared + that agent's private ones + platform `mspbots` ones) and whether each is switched on | `agent_id` (required) |
| `mspbotsagent_create_agent_skill` | Create a new private skill (scope=agent) for an agent, publish it as an npm version and install it into that agent's workspace | `agent_id` (required), `name` (required), `files` (required, must contain a `SKILL.md`) |
| `mspbotsagent_update_agent_skill_files` | Edit an agent's private skill files and publish a new version (whole-package replacement). Private skills owned by that agent only | `agent_id` (required), `capability_id` (required), `files` (required, the complete file set), `note` (optional, defaults to "Edit") |
| `mspbotsagent_delete_agent_skill` | Delete an agent's private skill: soft-delete the capability + clear the opt-out row + uninstall it from the workspace | `agent_id` (required), `capability_id` (required) |
| `mspbotsagent_set_agent_skill_enabled` | Turn one skill on or off for an agent without touching its files. Works on all three kinds, not just private ones | `agent_id` (required), `ref` (required, from the list tool), `enabled` (optional, default `true`), `skill_type` (optional, `mspbots`/`org`/`agent`, default `org`) |
`mspbotsagent_list_agent_skills` returns, for each skill,
`id`/`type` (`mspbots`|`org`|`agent`)/`ref`/`scope`/`name`/`skillName`/`description`/`version`/`enabled`/`selected`
(`true` when not opted out)/`available`, plus a `selectedIds` list. `selected` is
exactly the switch `mspbotsagent_set_agent_skill_enabled` writes, and each entry's
`ref` and `type` are what to pass back in as `ref` and `skill_type`.
> ⚠️ **The field structure of each element of `files` ("SkillFile") is not specified:
> the API spec handed over only said `"$ref": "#/definitions/SkillFile"`, without
> attaching that definitions block.**
> This implementation assumes `{"path": "<relative path, e.g. 'SKILL.md'>", "content": "<raw text content>"}`
> — inferred from the common SKILL.md packaging convention, **not verified against the
> real API**. Confirm the actual field names with the backend before relying on it
> (whether there is also an `encoding`/`executable`, etc.).
>
> Backing endpoints: `GET /api/agents/:id/skills`, `POST /api/agents/:id/skills/create`,
> `PUT /api/agents/:id/skills/:capabilityId/files`, `PUT /api/agents/:id/skills`,
> `DELETE /api/agents/:id/skills`
> (the last one's request body is `{"id": "<capabilityId>"}` — a DELETE with a body is
> unusual in HTTP, so this service sends it through httpx's low-level `request()`
> rather than `delete()`).
>
> `PUT /api/agents/:id/skills` takes `{ref, enabled, type}` and answers
> `{agentId, ref, enabled, restartRequired, pending}`. `restartRequired` is always
> `true`. `pending: true` means the flag was recorded but syncing the install/uninstall
> to the runtime failed, so the agent's capability is marked pending and will be retried
> later — the change is **not in effect yet**. An unresolvable `ref` or an unknown agent
> comes back as HTTP 200 with `{"success": false, "error": "…"}` rather than a 4xx, so
> the tool checks `success` explicitly and reports it as an error.
### SOP library (list + synchronous chat)
A **SOP** (standard operating procedure) is a tenant-level record, and creating one
also opens a **dedicated agent** for it. That agent is what
`mspbotsagent_chat_with_sop` talks to, which is why it takes only `sop_id`, never
`agent_id`. This is a different thing from [Agent SOP author](#agent-sop-author) above:
those tools edit the **SOP draft sections** attached to an agent, while these read the
**SOP library** and actually run a turn of conversation.
| Tool | What it does | Parameters |
|---|---|---|
| `mspbotsagent_list_sops` | List the tenant's SOPs with pagination, returning each one's status and owning agent | `search` (fuzzy match on name only, does not search the body), `status` (`draft`/`published`), `page` (default 1), `page_size` (default 20, max 100) |
| `mspbotsagent_chat_with_sop` | Hold **one synchronous turn** of conversation with a SOP's agent: blocks until the whole turn finishes, then returns the reply | `sop_id` (required), `message` (required), `thread_id` (continue the same conversation), `new_thread` (start a new one) |
`mspbotsagent_list_sops` returns per row
`id`/`name`/`description`/`status`/`source`/`tags`/`agentId`/`agentLive`/`updatedAt`.
`agentLive` being `false` means that agent record no longer exists — the SOP is still
readable, but it cannot be talked to, and no parameter on either tool can bring it back.
**How "the user names a SOP in their own words" turns into a `sop_id`:**
the backend's `search` only does `name ILIKE '%…%'` (`service/sop/db.ts:88`) — it does
not match description / tags / body, and there is no fuzzy, synonym or cross-language
matching. **So a miss does not mean it does not exist**; it usually just means the
wording differs (the user says "the refund one", the SOP is called `Refund Handling`).
Dropping the user's own words straight into `search`, getting 0 rows, and then
answering "there is no such SOP" is the most expensive mistake on this path. The right
move is to **not pass `search`**, read each row's `name` + `description` and pick it
yourself, using `total` to decide whether to page. Use `search` only for a name fragment
you already know for certain. This guidance lives in the `search` parameter's own
description (parameter descriptions do not count towards the 500-char limit), and is
guarded by `test_search_param_warns_that_a_miss_is_not_an_absence` in
`tests/test_sops.py`.
**When you only have an `agent_id`**: every `list_sops` row carries `agentId`, so list
them and match on it to get the `sop_id`; `mspbotsagent_get_usage_overview` rows carry
both `sopId` and `agentId` too. Do not try to parse the agent's name — although a SOP
agent is created as `${sop.name} #${sop.id}` and renames propagate, `PUT /api/agents/:id`
lets a user change `name` directly, which decouples it immediately, and an ordinary
agent can be given the same name too.
`mspbotsagent_chat_with_sop` returns
`sopId`/`sopName`/`agentId`/`threadId`/`status`/`reply`, where `status` explains an
empty reply:
| `status` | Meaning |
|---|---|
| `completed` | Ran normally; `reply` is the last user-visible assistant message of this turn |
| `interrupted` | Paused on a human approval gate, with `pausedActions` attached; someone has to decide in the Agent Platform before it continues |
| `no_reply` | Finished, but produced no assistant message |
`reply` is extracted the same way the frontend's `lastAssistantText` does it in
`components/assistant-ui/aegra/messages.ts`: scan backwards for a
`type=ai`/`role=assistant` message, skipping the internal messages the UI hides
(`rubric_grader`, `sop_context`, `sop_suffix`, `compact_summary`, `task_notification`).
> ⚠️ **This call path differs from the other tools in two ways — do not revert them to
> the defaults:**
> 1. It **turns retries off** (`retries=0`). A request that runs an agent is not
> idempotent — re-sending after a lost response **runs the agent again** instead of
> recovering the first result.
> 2. It raises the read timeout to **300s** (the 30s default cannot survive a real agent
> turn; Aegra's own backstop is 1 hour). The error returned on timeout carries
> `retryable=false` and says explicitly: do not re-send, ask again on the same
> conversation instead.
>
> Backing endpoints: `GET /api/sops` (returns `{list, total}` when paginated),
> `GET /api/sops/:id` (to get `agent_id`), `POST /api/agents/:id/run/wait`. That last
> one **signals a failed run with HTTP 200 + `{"success": false}`** rather than a
> 4xx/5xx, so this tool checks the `success` field explicitly to avoid reading a failure
> as "succeeded but said nothing".
## Quick Start
### Docker (recommended)
```bash
docker compose up --build
```
The server starts on `http://localhost:8080`.
### Local (uv)
```bash
uv sync
python -m mspbots_agent_mcp
```
## Health Check
```bash
curl http://localhost:8080/health
# {"status": "ok"}
```
No credentials are required for the health endpoint.
## Authentication
Every request to `/mcp` must include the following HTTP headers (provided by the
MCP caller — kept consistent with `ticketqa-mcp`):
| Header | Type | Required | Description | Example |
|---|---|---|---|---|
| `X-MSP-Token` | string | Yes | An access credential already issued by the Agent Platform (JWT bearer token). This service forwards it verbatim as the downstream request's `Authorization: Bearer <token>`. | `X-MSP-Token: <jwt-bearer-token>` |
| `X-MSP-Tenant-Id` | string | Yes | Tenant identifier. Renamed to the `X_Tenant_ID` header when forwarded to the downstream API (the tenant is also embedded in the JWT). | `X-MSP-Tenant-Id: <tenant-id>` |
| `X-MSP-Host` | string | Yes | The host the Agent API lives on. | `X-MSP-Host: https://agent.mspbots.ai` |
Missing any of the three headers returns `401 Unauthorized`.
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `MCP_HTTP_PORT` | `8080` | Listening port |
| `MCP_HTTP_HOST` | `0.0.0.0` | Listening host |
## MCP Endpoint
```
POST http://localhost:8080/mcp
```
Connect your MCP client with:
- Transport: `http` (Streamable HTTP / SSE)
- Headers: `X-MSP-Token`, `X-MSP-Tenant-Id`, `X-MSP-Host` (all required)
## Test Example
```bash
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "X-MSP-Token: <token>" \
-H "X-MSP-Tenant-Id: <tenant-id>" \
-H "X-MSP-Host: https://agent.mspbots.ai" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "mspbotsagent_get_connectors", "arguments": {} }
}'
```
> ⚠️ This is a public repository. Never write real tokens, tenant ids or other
> sensitive values into any committed file — the `<token>` / `<tenant-id>` above are
> placeholders only.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues