Skip to main content
Glama
ITbuilder-Ltd

IT Glue MCP Server

README.md
# IT Glue MCP Server

An MCP server over the IT Glue API for every resource and action except anything to do with passwords.
It runs in a container, or as a local process over stdio, and works with MCP clients such as Claude Code
and Copilot Studio.

This is an unofficial project. It is not affiliated with, endorsed by or supported by Kaseya or IT Glue.

## Safety

IT Glue holds documentation and customer passwords, has no sandbox, and an API key can read and write
every organisation the account has. So the code, not care, limits what the key can do:

- **No passwords, ever.** The server has no tool, path, include, field or export that reads or writes a
  password, a password folder or a password category. Requests that mention one are refused on the way
  in, and anything that points at one is removed on the way out, whatever the key's Password Access
  setting.
- **Reads everything, writes nothing, until told otherwise.** The default policy reads every
  organisation and writes none. Writes, deletes and account-wide writes are switched on one by one, and
  writes only reach the organisations on an allow-list. A tool the policy can never perform is not
  offered at all.
- **Restricted records are withheld.** Records IT Glue marks restricted, what belongs to them, and
  documents and folders inside a restricted folder are left out of every reply and counted, unless the
  deployment turns that off.
- **Credential-like text in documents is redacted** before a model reads it, by one policy wherever a
  document's prose appears. A write's reply names the record and never repeats its content.
- **Callers sign in with Microsoft Entra ID**, and need an app role for this server: `reader` reads,
  `writer` writes. A caller with neither is refused before any tool runs, and a tool call whose sign-in
  is not admitted is refused, never served read-only. A reader is handed an IT Glue client with every
  write switched off, so no tool can write on a reader's behalf.
- **HTTP with no sign-in answers only the loopback names**, so a web page cannot reach the server through
  DNS rebinding.
- **No live call without `ITGLUE_LIVE=1`.** The key lives only in the environment, never in the image.
- **Every failure is an error**, never an empty result. A change whose connection dropped says its
  outcome is unknown.
- **Every reply carries what a person needs to check it:** the record's last-updated date and its IT
  Glue link, and a count of what was withheld.

## Quick start, with Docker

1. Create an API key in IT Glue under Admin > Settings > Custom API Keys, with **Password Access off**.
   Copy `.env.example` to `.env` and put the key in it.
2. Say who may connect. Either configure sign-in, as described under
   [Sign in with Microsoft Entra ID](#sign-in-with-microsoft-entra-id), or, to try the server on this
   machine with nobody signing in, set `ITGLUE_MCP_UNAUTHENTICATED=1` in `.env`.
3. `docker compose up -d mcp`. The server listens at `http://127.0.0.1:8000/mcp`, on this machine only.
4. Connect a client, for example:

   ```
   claude mcp add --transport http itglue http://127.0.0.1:8000/mcp
   ```

With only the key set, the server is read-only. To allow writes, name where they may go in `.env`:

```
ITGLUE_WRITES=1
ITGLUE_WRITE_ORG_IDS=<the organisation ids that may be written to, separated by commas>
```

Without sign-in the server refuses to listen on an address other machines can reach unless
`ITGLUE_MCP_UNAUTHENTICATED=1` says that is intended. `compose.yaml` publishes the port on `127.0.0.1`
only, which is what makes that acceptable. Do not publish the port further without sign-in. The
container runs as a non-root user with a read-only filesystem and no capabilities, and keeps the
sign-in proxy's state on the `signin-state` volume. `compose.yaml` passes the container only the settings
the server reads, listed by name, so nothing else in `.env` reaches it. `docker inspect` shows a
container's environment, key and client secret included, to anyone who can use Docker on the host.

## Sign in with Microsoft Entra ID

Over HTTP the server signs callers in with Microsoft Entra ID. People sign in through the server's own
OAuth proxy (fastmcp's `AzureProvider`), which gives MCP clients the dynamic client registration that
Entra lacks, so Copilot Studio's dynamic discovery, Microsoft 365 Copilot and Claude Code can connect
with nothing but the server's URL. Applications, such as an Entra agent identity, present an access
token they got from Entra for this server, and the server checks it directly. stdio never signs in: it
answers only the program that started it, which already holds the IT Glue key. Every other transport
signs in, and the server refuses to start with a transport other than `http` or `stdio`.

Who gets in, decided from the Entra access token's claims:

| Caller | The token needs | Gets |
|---|---|---|
| A person (the token has `scp`) | `mcp.access` in `scp`, and the `writer` or `reader` app role | `writer`: the deployment's policy, writes included. `reader`: reads only |
| An application (no `scp`) | `idtyp` of `app`, and the `writer` or `reader` app role | The same |
| Anything else | | Refused with HTTP 401 before any tool runs |

The token must also be a v2.0 access token from your tenant (its issuer), for this app (its audience,
the client id or `api://<client id>`), signed by one of the tenant's keys, unexpired, and already valid.
The same rule runs for both kinds of caller, at one place, on every request. A person with no role who
signs in through the proxy is refused at the token endpoint with an OAuth error that says the role is
missing, and nothing is kept for them. A role removed later does not take effect at the next call: every
call is checked against the roles in the Entra token the proxy holds for that person, and that token
is only replaced when it expires or the proxy refreshes it, so the person keeps their access until then,
and is refused at that refresh. Entra decides how long the token lasts. An application's token is
checked the same way, until its own expiry. The server does not check `azp`, the client application a
person signed in through: any client your tenant lets request `mcp.access` can be used, so control that
with consent (step 1).

Each refusal is logged at WARNING with its reason, and with the caller's `azp`, `oid` and roles once
the token's signature is verified; a token that fails verification is logged with the kind of failure
only. An admitted call is logged at DEBUG. No token, authorization code or query string is ever logged:
uvicorn's access log, which would write the sign-in callback's code, is off, and each sign-in request is
logged with its method, path and status.

To set it up:

1. Register an app in Entra ID, single tenant. Under Expose an API, set the Application ID URI to
   `api://<client id>` and add the delegated scope `mcp.access`, with **Who can consent: Admins only**,
   so that no user can consent to it for a client application of their own choosing; grant admin consent
   for the clients you use. In the manifest set `requestedAccessTokenVersion` to `2`. Add two app roles, `writer` and `reader`, each allowed for
   users and groups and for applications. Add the Web redirect URI `<public URL>/auth/callback`, and
   create a client secret.
2. Under Token configuration, add the optional claim `idtyp` to the access token. Entra leaves it out
   by default, and without it the server cannot tell an application's token from a person's ID token,
   so it refuses every application.
3. Assign the roles. People and groups get `reader` or `writer` under the enterprise application's
   Users and groups. An application or agent identity gets one through Microsoft Graph
   (`appRoleAssignedTo` on the server's service principal). Entra issues no token for this app to an
   application with no role.
4. Set the variables below in `.env` and start the server. `ITGLUE_MCP_PUBLIC_URL` is where clients reach
   it, with no path: `https://` for another machine, or `http://localhost:8000` on this one.
5. Connect. In Copilot Studio, add an MCP tool with the URL `<public URL>/mcp` and OAuth 2.0 dynamic
   discovery. The proxy sends a sign-in back only to an address `ITGLUE_MCP_ALLOWED_REDIRECT_URIS`
   allows. By default that is any port on `localhost` and `127.0.0.1`, for clients on this machine such
   as Claude Code, and `https://global.consent.azure-apim.net/redirect*`, where Copilot Studio's
   connectors are believed to receive theirs. That address is not yet verified against Copilot Studio,
   and it is shared by every tenant's connectors: once your connector's own redirect URL is known, set
   the variable to it and the loopback patterns alone. A client may not name itself by a URL for the
   server to fetch (CIMD). In Claude Code, `claude mcp add --transport http itglue <public URL>/mcp`, which signs in
   through the browser. The first sign-in from each client shows the proxy's consent page. Use the
   public URL exactly as configured, so `http://localhost:8000` and not `http://127.0.0.1:8000`: the
   server's sign-in metadata names it, and a client may refuse metadata for another address.

The proxy keeps client registrations, sign-ins in progress and the Entra tokens it holds, encrypted,
under `FASTMCP_HOME` (`/data` in the image), which the server makes readable by its own user only. On a
read-only filesystem that must be a writable volume, as `compose.yaml` mounts, or the server refuses to
start and says why. Without a volume, every restart forgets every registered client. Run one replica: a
second would not see the first one's state. The proxy's signing key derives from the client secret
unless `ITGLUE_MCP_SIGNING_KEY` is set, so set it to let the secret rotate without signing everyone out.

Registration (`/register`) needs no sign-in, as dynamic client registration requires, and each one is
kept for good. The server refuses a sign-in request body over 64 KB and sweeps what has expired from the
store at most every ten minutes, but it does not limit how many requests arrive: the ingress in front of
it should rate-limit `/register`, `/authorize` and `/token`.

With sign-in, the server answers requests addressed to the public URL's host and to the loopback names,
and accepts a browser's `Origin` only from the public URL. A reverse proxy in front must keep the `Host`
header.

## Configuration

Everything is an environment variable. `compose.yaml` passes the container each variable below that
`.env` sets, by name, and nothing else from `.env`. Outside a container, the IT Glue client also reads the `ITGLUE_` settings it uses, other
than `ITGLUE_LIVE`, from a `.env` file at the repository root, and the environment wins. The server's
own `ITGLUE_MCP_` settings, sign-in included, come from the environment only.

| Variable | Meaning |
|---|---|
| `ITGLUE_LIVE` | Must be `1`, or nothing is sent. `compose.yaml` sets it |
| `ITGLUE_API_KEY` | The IT Glue API key |
| `ITGLUE_BASE_URL` | IT Glue's region: `https://api.itglue.com`, `https://api.eu.itglue.com` (default) or `https://api.au.itglue.com` |
| `ITGLUE_READ_ORG_IDS` | Organisation ids that may be read, separated by commas. Empty reads every organisation |
| `ITGLUE_WRITE_ORG_IDS` | Organisation ids that may be written to. Empty writes none |
| `ITGLUE_WRITES` | `1` allows creating, updating and publishing, within the write list |
| `ITGLUE_DELETES` | `1` also allows deleting. Needs `ITGLUE_WRITES=1` |
| `ITGLUE_ACCOUNT_WRITES` | `1` also allows writing records that belong to no organisation, such as types and statuses. Needs `ITGLUE_WRITES=1` |
| `ITGLUE_MCP_HOST`, `_PORT`, `_PATH` | Where to listen: `127.0.0.1`, `8000` and `/mcp` by default |
| `ITGLUE_MCP_UNAUTHENTICATED` | `1` accepts that nobody signs in, so that the server may listen on an address other machines can reach. Refused beside sign-in |
| `ITGLUE_MCP_SERVE_RESTRICTED` | `1` serves records IT Glue marks restricted. Withheld by default |
| `ITGLUE_MCP_ENTRA_TENANT_ID`, `_CLIENT_ID`, `_CLIENT_SECRET` | The Entra tenant, the app registration and its client secret. Any of them turns sign-in on, and then all three and `ITGLUE_MCP_PUBLIC_URL` are needed. The ids are GUIDs, in either case |
| `ITGLUE_MCP_PUBLIC_URL` | Where clients reach the server, with no path, user or password. The Entra redirect URI is this plus `/auth/callback` |
| `ITGLUE_MCP_ALLOWED_REDIRECT_URIS` | Where the sign-in proxy may send a client back to, separated by commas, `*` for any port or the rest of a path: `https://` addresses, or `http://` on `localhost` or `127.0.0.1`. A wildcard host must be a single leading `*.` before a domain you own, as in `https://*.example.com/*` (the only form the sign-in proxy matches), with no empty labels or trailing dot: `https://*.com/*` and `https://*.*.example.com/*` are refused. The check has no public suffix list, only a short built-in one of common cases, so never wildcard a shared hosting domain such as `github.io` or `azurewebsites.net`. Empty: any loopback port, and Copilot Studio's connector redirect |
| `ITGLUE_MCP_ENTRA_SCOPE` | The delegated scope a person's token needs: `mcp.access` by default |
| `ITGLUE_MCP_READER_ROLE`, `ITGLUE_MCP_WRITER_ROLE` | The app role values: `reader` and `writer` by default |
| `ITGLUE_MCP_SIGNING_KEY` | Optional, 32 characters or more. Signs the proxy's tokens and encrypts its state, instead of a key derived from the client secret |
| `FASTMCP_HOME` | Where the sign-in proxy keeps its state: `/data` in the image |

## Tools

Ten tools reach every operation the server supports. Every input has one JSON type and an empty
default (`""`, `0` or `"{}"`), because Copilot Studio drops a tool whose schema has a union or a
reference. Objects travel as JSON text and lists of ids as comma-separated text.

| Tool | What it does | Hints |
|---|---|---|
| `describe_resources(resource="")` | Every resource and what it supports, or how one is addressed and which filters, sorts and includes it allows | read-only |
| `list_records(resource, organization_id, parent, parent_id, filters, sort, include, fields, page_number, page_size)` | One page of a resource's records, with `page.total` and `page.next` | read-only |
| `get_record(resource, record_id, organization_id, parent, parent_id, include, fields)` | One record, whole | read-only |
| `find_documents(organization_id, title_contains, folder_id, limit)` | An organisation's documents whose title holds every word | read-only |
| `get_document(organization_id, document_id, max_chars)` | One document as text | read-only |
| `list_document_folders(organization_id, name_contains, limit)` | The folders, by path, with their document counts | read-only |
| `create_record(resource, attributes, organization_id, parent, parent_id)` | Create one record | writes |
| `update_records(resource, records, organization_id, parent, parent_id)` | Change one record, or several in one bulk update | writes, destructive |
| `publish_document(document_id)` | Publish a draft document | writes |
| `delete_records(resource, record_ids, organization_id, parent, parent_id)` | Delete records by id, never by filter | writes, destructive |

`resource` is an enum built from the resource registry, narrowed to the resources the tool can reach, so
a resource added to the registry reaches the right tool with no edit to the tools. Documents and their
folders are reached only through the document tools.

### Finding a document

IT Glue has no document search, and it lists documents one folder at a time. `find_documents` therefore
reads the folders and lists them in a fixed order (the root's first page, folders named for the topic,
then the rest by size, then the rest of the root), matching titles in memory, ignoring case. One call
stops after twelve requests or twenty seconds, though it always reads at least one page, or at `limit`
matches. Its reply then says `complete: false`, names the folders left, and gives `continue_from`, which
the next call passes back with the same query to read on from the first document it did not give; or a
model picks folders with `list_document_folders` and asks again with `folder_id`, which searches that
folder first and then the folders inside it. Nothing is cached between calls.

IT Glue also lists a document with its whole content, so a listing is heavy. The server reads documents
a page of 100 at a time, and checks that every row names the folder asked for, because IT Glue silently
ignores a filter it does not know.

## Without Docker

```
python -m venv .venv
.venv/Scripts/pip install -e ".[server]"        # .venv/bin/pip on Linux and macOS
export ITGLUE_LIVE=1                            # and ITGLUE_API_KEY, or a .env file
.venv/Scripts/python -m itglue_mcp serve                     # HTTP on 127.0.0.1:8000
.venv/Scripts/python -m itglue_mcp serve --transport stdio   # or stdio, for a client that launches it
```

## Development

```
.venv/Scripts/python -B -m unittest discover -s tests -t .
```

`scripts/agent_identity.py check` calls a signed-in server as an Entra agent identity, and `bridge` serves
it to a coding agent over stdio. It reads the agent identity's credentials from `.env.agent` at the
repository root, or the file `--credentials` names, and never from the environment or `.env`. Git and
the image's build context ignore `.env.agent` and any other `.env.*` file; a file named otherwise is not
ignored, so keep the credentials in one named `.env.something`. Keep them there: the blueprint's client secret mints
a token for every agent identity under the blueprint, so it must not reach the server's container.

A few key unit tests, and integration tests that drive the server through FastMCP's in-memory client over
a local fake IT Glue (`tests/support.py`). Sign-in is tested over HTTP with tokens signed by a local key
(`tests/entra_fake.py`). A safety net in the tests blocks the real `.env` and any network beyond
127.0.0.1.

| Path | Job |
|---|---|
| `itglue_mcp/api/` | The IT Glue client: the resource registry, transport, retries, pagination, the password guard, the access policy |
| `itglue_mcp/server/` | The FastMCP server over the client: the tools, sign-in, who gets which client, replies, errors and the front door |
| `itglue_mcp/server/reading/` | A document's content blocks as text, with credential-like text redacted |