@wit-secops/halopsa-mcp
v0.8.0
Published
MCP server for the HaloPSA API — tickets, ticket actions, cross-ticket action history, clients, sites, users, assets, projects, calendars, and lookups, with guarded ticket write tools. Read-only by default.
Downloads
883
Maintainers
Readme
halopsa-mcp
A Model Context Protocol (MCP) server for the HaloPSA API, by WIT-SecOps. It lets AI assistants search and read tickets, read a ticket's notes and emails, see who worked on what across all tickets in a date window, look up clients, sites, end users, assets, projects and calendars, and look up agents, teams, statuses and ticket types. With --read-write it can also add private notes, set a status the user names, assign tickets, and move a ticket to the right client. With --allow-email on top, it can also email a ticket's end user a reply you've approved.
Note: Not affiliated with or endorsed by Halo Service Solutions.
Features
- 20 read tools covering tickets, ticket actions (notes, emails, status changes), cross-ticket action history, clients, sites, end users, assets, projects, calendars, and the agent/team/status/ticket-type lookups
- 4 write tools in read-write mode: private notes, status changes, assignment, and setting a ticket's client
- Customer replies behind a separate
--allow-emailswitch: emails only the ticket's own end user, only to an address you confirmed, with no CC - Read-only by default, enforced by Halo: in read-only mode the OAuth token itself carries only read scopes, so Halo refuses writes even when the API application could make them
- Guarded writes: notes are always private with email off and posted exactly as written, with no attribution line; a status is set only by the exact name the user gave; every write sends one record for a ticket fetched first, and is never retried automatically
- Attribution by action author, not by assignee, for "who worked on what" and time tracking
- Trimmed responses by default (a Halo ticket has about 300 fields), with a
fulloption when you need everything - Status, ticket type and agent names resolved from cached lookups (Halo's ticket lists carry only ids)
- Throttled to about 2 requests per second to stay inside Halo's shared 700-per-5-minutes budget
- Markdown and JSON response formats
- Runs locally over stdio; each MCP client spawns its own process
Quick Start
Claude Desktop
- Open Claude Desktop and go to Settings > Developer > Edit Config
- Add the following to
claude_desktop_config.json:
{
"mcpServers": {
"halopsa": {
"command": "npx",
"args": ["-y", "@wit-secops/halopsa-mcp@latest"],
"env": {
"HALOPSA_BASE_URL": "https://yourcompany.halopsa.com",
"HALOPSA_TENANT": "yourcompany",
"HALOPSA_CLIENT_ID": "your-client-id",
"HALOPSA_CLIENT_SECRET": "your-client-secret"
}
}
}
}- Save the file and restart Claude Desktop
Config file locations:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json. The Microsoft Store build reads%LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude_desktop_config.jsoninstead, so use Edit Config rather than editing the file by path.
Other MCP Clients (Cursor, Windsurf, Claude Code, etc.)
Most MCP clients use the same stdio configuration format shown above.
Use @wit-secops/halopsa-mcp@latest rather than the bare package name. With the bare name, npx run from inside a checkout of this repo resolves to the local project and fails with "'halopsa-mcp' is not recognized"; @latest always fetches the published package.
If your credentials live in a secrets runner, start the server through it and name only the four connection variables, for example "command": "kryptos", "args": ["run", "--only", "HALOPSA_BASE_URL,HALOPSA_TENANT,HALOPSA_CLIENT_ID,HALOPSA_CLIENT_SECRET", "--", "npx", "-y", "@wit-secops/halopsa-mcp@latest"] with no env block. Leaving out a shared HALOPSA_SCOPE keeps it from narrowing this server's scopes.
Environment Variables
| Variable | Description | Required | Default |
|----------|-------------|----------|---------|
| HALOPSA_BASE_URL | Your Halo tenant URL, e.g. https://yourcompany.halopsa.com (a trailing /api is fine) | Yes | |
| HALOPSA_CLIENT_ID | Client ID of a Halo API application using "Client ID and Secret (Services)" | Yes | |
| HALOPSA_CLIENT_SECRET | That application's client secret | Yes | |
| HALOPSA_TENANT | Tenant name for the token request; needed on Halo-hosted tenants | Hosted: yes | |
| HALOPSA_SCOPE | Narrow the requested scopes further (space- or comma-separated). It can only remove scopes, never add them | No | the mode's scopes |
| HALOPSA_ALLOW_WRITE | true enables the write tools, like --read-write | No | read-only |
| HALOPSA_NOTE_OUTCOME | The action outcome halopsa_add_note uses. It must not change the ticket's status | No | Private Note |
| HALOPSA_ALLOW_EMAIL | true enables halopsa_send_reply, like --allow-email; needs read-write mode | No | off |
| HALOPSA_EMAIL_OUTCOME | The action outcome halopsa_send_reply uses to email the end user | No | Email User |
CLI Options
--base-url <url> Halo tenant URL (overrides HALOPSA_BASE_URL)
--tenant <name> Tenant name (overrides HALOPSA_TENANT)
--read-write Enable the write tools (default: read-only)
--allow-email With --read-write, also enable halopsa_send_reply (emails the end user)
--read-only Force read-only mode (the default)
--help, -h Show help
--version, -v Show versionThere's no flag for the client secret, so it never appears in process listings. All logging goes to stderr.
Access Modes
The server is read-only by default. Each mode registers its own tools and asks Halo for a token with only these scopes:
| Mode | Tools | Scopes requested |
|------|-------|------------------|
| read-only (default) | the 20 read tools | read:tickets read:customers read:assets read:calendar |
| read-write (--read-write or HALOPSA_ALLOW_WRITE=true) | adds the 4 write tools | adds edit:tickets |
| read-write + email (also --allow-email) | adds halopsa_send_reply | same as read-write |
Halo enforces scopes on the token: a token without read:tickets gets HTTP 403 from /Tickets. So even when the Halo API application behind the credentials is allowed to edit tickets, a read-only server can't. Halo silently drops any requested scope the application isn't granted, so asking for more never fails, it just doesn't widen anything. If HALOPSA_SCOPE leaves out edit:tickets, a read-write server warns and runs read-only rather than offering write tools that would fail.
Install the two editions side by side under different names. The only difference is --read-write:
{
"mcpServers": {
"halopsa": {
"command": "npx",
"args": ["-y", "@wit-secops/halopsa-mcp@latest"],
"env": { "HALOPSA_BASE_URL": "…", "HALOPSA_TENANT": "…", "HALOPSA_CLIENT_ID": "…", "HALOPSA_CLIENT_SECRET": "…" }
},
"halopsa-write": {
"command": "npx",
"args": ["-y", "@wit-secops/halopsa-mcp@latest", "--read-write"],
"env": { "HALOPSA_BASE_URL": "…", "HALOPSA_TENANT": "…", "HALOPSA_CLIENT_ID": "…", "HALOPSA_CLIENT_SECRET": "…" }
}
}
}Each person should use their own Halo API application, so their writes appear under their own name in Halo. Writes go out under the agent that application logs in as.
The mode flag is not a security boundary on its own. What actually limits access is which Halo API application's credentials a person receives, the scopes and permissions on that application, and the Halo agent it logs in as.
Tools
Connection
| Tool | Description |
|------|-------------|
| halopsa_whoami | Which Halo agent the server signs in as, the tenant and Halo version, the mode and scopes |
Tickets
| Tool | Description |
|------|-------------|
| halopsa_list_tickets | List and filter tickets: ids, client, site, user, agent, team, status, type, open/closed, text search, opened/closed date range, updated-since |
| halopsa_get_ticket | One ticket by id: working fields plus body, reporter, priority, SLA, categories and closure note, or every field and custom field value with detail: "full" (nested objects reduced to id and name) |
Actions
| Tool | Description |
|------|-------------|
| halopsa_list_ticket_actions | One ticket's history, oldest first: notes, emails, status changes, reassignments, with author, visibility and time logged |
| halopsa_list_actions | Every action in a UTC date window across all tickets, optionally only one agent's, with per-author totals |
Clients, sites and users
| Tool | Description |
|------|-------------|
| halopsa_list_clients | Clients (customers), active only by default; name search |
| halopsa_get_client | One client by id, including CRM account records the list leaves out: main site, website, account manager and techs, the matching IT Glue organization id |
| halopsa_list_sites | Sites, by client and/or name search |
| halopsa_list_users | End users, by client, site, and/or a search that matches names and email addresses |
| halopsa_get_user | One end user by id, including their email address (list rows leave it out) |
Assets
| Tool | Description |
|------|-------------|
| halopsa_list_assets | Assets (devices, configuration items), by client, site, asset type, and/or a search that matches the asset tag (the hostname for synced devices) and name |
| halopsa_get_asset | One asset: status, owners, the linked IT Glue configuration (id and link), and its type's fields such as model, serial number and OS |
| halopsa_list_asset_types | Asset types and their ids, for the asset_type_id filter |
Projects
| Tool | Description |
|------|-------------|
| halopsa_list_projects | Projects (tasks only with include_tasks), by client, agent, open/closed and search: status, dates, estimated and logged hours, task count |
| halopsa_get_project | One project with its description and tasks (status, agent, target date, hours for each) |
Projects are Halo tickets of a project type, so read:tickets covers them, and their notes and time are actions (halopsa_list_ticket_actions). Which ticket types are projects comes from Halo's own ticket-type settings (project_type), not hard-coded ids.
Calendars
| Tool | Description |
|------|-------------|
| halopsa_list_appointments | Appointments and tasks overlapping a UTC window, by agent, client, ticket, search, kind and completion |
Private appointments: most items on a real Halo calendar are agents' private appointments (synced personal calendars), and the API agent can read them. The server shows another agent's private item only as "Private appointment" with its time and agent: no subject, type, client, ticket, location or meeting link. Private items belonging to the server's own agent (halopsa_whoami) are shown in full.
Writes (read-write mode only)
| Tool | Description |
|------|-------------|
| halopsa_add_note (write) | Add a private note: always hidden from the end user, Halo's send-email option off, never changes status or assignment. The text is posted exactly as given |
| halopsa_update_ticket_status (write) | Set the status the user explicitly named, closed included. The name must match a Halo status exactly (ignoring case); otherwise it lists close matches and changes nothing |
| halopsa_assign_ticket (write) | Set the agent and/or team. Refuses disabled agents and inactive teams; sends only the fields that change |
| halopsa_set_ticket_client (write) | Move a ticket to the right client, site and end user (for example off the placeholder "Unknown" client). Client, site and user must belong together; the site defaults to the user's site or the client's main site, and the user to the site's "General User". Halo's rules, automations and acknowledgement email are off for the change; Halo clears "reported by" and adds a private "User Changed" note |
| halopsa_send_reply (write, email) | Email the ticket's end user a reply, through the tenant's "Email User" action. Off unless --allow-email is set. Goes only to the ticket's own end user, and only when confirm_recipient matches their address; refuses "General User", contacts set never to receive email, and users without an address; no CC. The text is sent exactly as given. Halo may move the ticket to a status such as "With User" |
Every write fetches the ticket first, sends exactly one record with the ticket's id (Halo silently creates a record when the id is missing), and is never retried automatically, because a timed-out write may still have been applied. If a write times out, check the ticket before trying again.
Halo applies its usual rules to a status change, which on some tenants include emailing the end user when a ticket closes.
Lookups
| Tool | Description |
|------|-------------|
| halopsa_list_agents | Agents and their ids |
| halopsa_list_teams | Teams and their ids |
| halopsa_list_statuses | Ticket statuses and their ids |
| halopsa_list_ticket_types | Ticket types and their ids |
Lookups are cached in the server process for 15 minutes.
Filter Semantics
- Ticket filters are exact and applied by Halo. Resolve names to ids first with the lookup tools.
- Halo ignores filters it doesn't support, without an error, so every filter this server sends has been checked against a live tenant. Two documented ones don't work and are worked around: ticket type is sent as
requesttype(Halo ignorestickettype_id), and closed-date ranges usedateclosed(Halo ignoresdatecleared). Halo'slastupdatetodatealways returns nothing, so there'supdated_sincebut no "updated before". searchmatches the ticket summary and details, not notes. On users it matches names and email addresses; on clients and sites, names.- Inactive records: the client, site, user and asset lists return active records only unless
include_inactiveis true. - Assets by hostname:
halopsa_list_assets' search matches the asset tag, which for RMM-synced devices is the hostname, so a hostname from CrowdStrike or IT Glue finds the Halo asset. Halo'sasset_idticket filter isn't used: in testing it returned no tickets for a real asset and every ticket for a made-up id. - CRM accounts: Halo leaves CRM account records (
is_account) out of the client list and its search, buthalopsa_get_clientfetches them by id. - Name filters on the lookup tools are case-insensitive substring matches applied locally.
- Action authorship: Halo can't filter actions by author, so
halopsa_list_actionsfetches the whole window and matchesagent_idlocally against the action's author (who_agentid, elseactionby_agent_id). With an agent, it also asks Halo for agent-performed actions only, which roughly halves the pages without dropping any (checked against a live tenant). - Action ids restart at 1 on every ticket. Actions are identified by
ticket_id+id. - Dates are UTC. Pass
"2026-09-24"or"2026-09-24T13:00:00".
Setting Up the Halo API Application
- In Halo, go to Configuration > Integrations > Halo API > View Applications > New.
- Choose Client ID and Secret (Services) as the authentication method.
- Choose which agent the application logs in as. Every result is limited to what that agent can see, so a dedicated agent with only the access you intend is the safest choice.
- Under Permissions, grant
read:tickets,read:customers,read:assetsandread:calendar, plusedit:ticketsif this person will use the write tools. Never grantalloradmin. - Save, and copy the client secret straight away: Halo shows it only once.
Rate Limits
Halo allows 700 requests per rolling 5 minutes, and that budget is shared by every integration on the tenant. The server spaces requests about 500 ms apart. Interactive calls fail fast on HTTP 429 with a clear message. halopsa_list_actions, which may fetch up to 50 pages, retries 429 and 503 up to three times (honoring Retry-After, else backing off with jitter). Writes are never retried.
Large fetches are capped rather than silently cut: a ticket's history stops at 1,000 actions and a date window at 5,000, and the response says when it's incomplete. A busy weekday has roughly 900–1,800 actions, so keep halopsa_list_actions windows to a few days. A fetched window is reused for 5 minutes, so paging through it with page_number costs no further requests (and actions newer than that can take up to 5 minutes to appear).
Response Size
Responses are capped at 25,000 characters. Markdown is cut with a note on how to narrow the request. JSON is never cut into invalid JSON: it's pretty-printed if it fits, compact if not, and otherwise trailing rows are dropped and a truncated object says how many were returned, so ask again with a smaller page_size.
Development
npm install
git config core.hooksPath .githooks # once per clone: runs `npm run check` before every push
npm run check # lint, format:check, build and test: the whole gate
npm run test:live # build, then call every tool against a real tenant (read-only)Checks run locally, not in GitHub Actions. The only workflow is publish.yml, which publishes to npm when a GitHub Release is published.
npm run test:live reads HALOPSA_BASE_URL, HALOPSA_TENANT, HALOPSA_CLIENT_ID and HALOPSA_CLIENT_SECRET from its environment, so run it under your secrets runner rather than keeping credentials in a file (for example kryptos run --only HALOPSA_BASE_URL,HALOPSA_TENANT,HALOPSA_CLIENT_ID,HALOPSA_CLIENT_SECRET -- npm run test:live; name the keys rather than HALOPSA_*, which would also pass a shared HALOPSA_SCOPE). It starts dist/index.js over stdio exactly as an MCP client would, passes it only those four values, and never prints them. Add -- --show to print each tool's output.
License
MIT
