Lets an AI assistant read your Gorelo PSA — your ticket backlog, clients, contacts and managed devices — by talking to the Gorelo API on your behalf.
Overview
Lets an AI assistant read your Gorelo PSA — your ticket backlog, clients, contacts and managed devices — by talking to the Gorelo API on your behalf. It runs on your own machine and speaks MCP over stdin/stdout. Nothing is hosted, nothing is exposed to the internet, and your API key never leaves your computer. Earlier versions of this README stated, as established fact, that and that per-user hours were impossible. That was true when it was written. Gorelo's release shipped GET /v1/time-entries — one row per logged entry, tenant-wide, each carrying the user who logged it — along with /v1/contracts, /v1/billing-roles and /v1/work-types. gorelo_time_report has been rebuilt on it. It no longer attributes a ticket's hours to the lead assignee, no longer warns that per-person totals are "indicative", and no longer costs one request per ticket. Details: what changed on 4 September 2026. One more thing from that release: until 3 October 2026 a , and a .
README
Gorelo MCP server
Lets an AI assistant read your Gorelo PSA — your ticket backlog, clients, contacts and managed devices — by talking to the Gorelo API on your behalf.
It runs on your own machine and speaks MCP over stdin/stdout. Nothing is hosted, nothing is exposed to the internet, and your API key never leaves your computer.
Twenty-three tools, nineteen of them read-only. Plain Ruby — no gems, no Bundler, no build step.
⚠️ Correction — 11 September 2026
Earlier versions of this README stated, as established fact, that Gorelo has no readable time-entry API and that per-user hours were impossible. That was true when it was written. It is false now. Gorelo’s 4 September 2026 release shipped
GET /v1/time-entries— one row per logged entry, tenant-wide, each carrying the user who logged it — along with/v1/contracts,/v1/billing-rolesand/v1/work-types.
gorelo_time_reporthas been rebuilt on it. It no longer attributes a ticket’s hours to the lead assignee, no longer warns that per-person totals are “indicative”, and no longer costs one request per ticket. If you built anything on the old limitation, or repeated it to anyone, it needs revisiting. Details: what changed on 4 September 2026.One more thing from that release: until 3 October 2026 a
contractwas what Gorelo’s web UI called a “Contract Group”, and aServiceLinewas what the UI called a “Contract”. That was true then; the UI now uses the API’s words. See contract naming.
Install
Pick whichever you already have. Both are equally supported.
Restart your MCP client. Then ask it something like “list my open Gorelo tickets” or “what have I got assigned that hasn’t been touched in 90 days?”
Configuration
| Variable | |
|---|---|
GORELO_API_KEY |
Required. Make it read-only to start with — only six tools write. See key scopes below. |
GORELO_MY_EMAIL |
Required for assignee: "me". Must match your Gorelo user exactly. |
GORELO_BASE_URL |
Defaults to https://api.aue.gorelo.io. Change for other regions. |
GORELO_ALLOW_WRITES |
true enables the six write tools. Off by default. |
GORELO_DOWNLOAD_DIR |
Where gorelo_get_invoice_pdf saves PDFs. Defaults to ~/gorelo-invoices. |
GORELO_ATTACH_DIR |
The only folder comment attachments can come from. Defaults to ~/gorelo-attachments. |
API key scopes
Since 3 October 2026 Gorelo lets you limit an API key per module to Read, Write or Delete (existing keys keep full access). Create this server’s key with:
- Read on every module you want the assistant to see;
- Write only on Tickets (comments, status and client changes), Time entries (only if
you use
gorelo_update_time_entryorgorelo_update_time_entries), Invoices (drafts) and Uptime (maintenance); - no Delete scope on any module.
Why: this server already has no DELETE call anywhere, and with no Delete scope Gorelo enforces
that too, so a bug or a hijacked key still can’t remove anything.
The tools
| Tool | Writes | |
|---|---|---|
gorelo_list_tickets |
The backlog view — assignee, client, status, title, staleness | |
gorelo_get_ticket |
One ticket in full, with its conversation | |
gorelo_search_clients |
Find a client by name fragment or id | |
gorelo_get_client |
One client’s whole footprint: tickets, contacts, devices | |
gorelo_get_contact |
Find a person by name or email across all clients | |
gorelo_list_assets |
Managed devices, filterable by client or last-seen age | |
gorelo_billing_review |
The Billing queue split by recorded hours — invoice, re-file, or set up a recurring charge | |
gorelo_time_report |
Recorded vs invoiceable vs billable hours, and realisation, by technician or client — from real per-entry data, so per-person totals are exact | |
gorelo_list_time_entries |
The individual entries behind a total: who, when, comment, work type, billing role, service line | |
gorelo_response_report |
First-response times — median, 90th, worst, share within target. Costs no extra requests | |
gorelo_list_contracts |
Contracts with their service lines, recurring amount, cost and margin | |
gorelo_billing_roles |
The sell-rate table — what an hour is worth under each role | |
gorelo_work_types |
Multipliers and per-entry minimum times — the two fields that change an invoice without changing the hours | |
gorelo_list_invoices |
Invoices by client, status, contract or date — names the ones approved but never emailed, and the overdue | |
gorelo_get_invoice_pdf |
Saves an invoice PDF to a local folder. ⚠ Gorelo logs every download as an export event | |
gorelo_get_contract |
One contract in full: schedule, service lines, labour terms, line items | |
gorelo_list_items |
The product and bundle catalogue with category, tax and margin; a bundle against its parts | |
gorelo_list_uptime |
Uptime checks and their maintenance windows — names the ones that never expire | |
gorelo_add_ticket_comment |
yes | Adds a comment, optionally with files from one folder. Off by default. |
gorelo_update_ticket |
yes | Sets a ticket’s client or status — nothing else. Off by default. |
gorelo_set_uptime_maintenance |
yes | Starts or ends a maintenance window on one check — nothing else. Off by default. |
gorelo_create_draft_invoice |
yes | Raises a manual invoice, always as a Draft. Off by default. |
gorelo_update_time_entry |
yes | Recodes one time entry’s work type, billable status, service line or comment — nothing else. Previews unless confirm: true. Gorelo re-prices the entry. Off by default. |
gorelo_update_time_entries |
yes | The batch form: previews up to 50 entries as one list, then applies them one by one and stops at the first error. Off by default. |
gorelo_api_probe |
Raw GET on any /v1/… path, for exploring |
The write tools
Four guards sit in front of gorelo_add_ticket_comment:
- Disabled unless
GORELO_ALLOW_WRITES=true confirm: truerequired on every callConversationTypeIdis always sent explicitly —2(Private) by default,1(Public) only whenvisibility: "client"is asked for. The field is optional in the API, so omitting it lets the server pick, and a private note posting publicly is the worst thing this tool could do. It is never left to a default.- Identical comments within 24 hours are refused. MCP has no transport-level
idempotency, so a retried call could otherwise post twice. A fingerprint log at
~/.gorelo-mcp-writes.jsonlprevents it. The fingerprint includes the visibility, so the same text cannot be posted once privately and once publicly by accident.
The body is sent as HTML, because that is what the API expects. Plain text is escaped and
paragraph-wrapped, so < and & cannot corrupt the markup.
The request body is CreatePublicCommentCommand: ConversationTypeId, Body,
CreatedByName, Attachments. ConversationId is rejected for Public and Private and is
never sent. The test mock enforces all of that — it rejects unknown fields — so the suite
fails if the payload drifts from the documented schema.
gorelo_update_time_entry and gorelo_update_time_entries
PATCH /v1/time-entries/{id}. Sends only WorkTypeId, BillableStatusId, ServiceLineId and
Comment. Hours, dates, technician, billing role and ticket are not reachable, and any other
argument is refused rather than ignored.
- No
confirm: true: preview only. The entry is read and shown with its ticket, date (Brisbane time), technician and hours, and each field as current → new. Nothing is written. The preview printsexpected_updated_on. confirm: true: the entry is read again, the write is refused if a suppliedexpected_updated_onno longer matches, only the fields that differ are sent, and the values Gorelo returns are printed.- Gorelo re-prices the entry when work type, billable status or service line changes: the work type’s minimum and increment, the role’s rate and the contract’s terms are re-applied and hours move between BlockHours / LimitedHours contracts. Approved, completed, invoiced and void entries are refused by Gorelo (409).
work_typeis a name or id matched against the live work types ("Peer Assist").billable_statusis Billable, Non-billable, No charge or Void: there is no endpoint that lists them, so the four values seen on real entries are built in.service_line_idis the API ServiceLine (older UI screens called it a Contract).- The batch tool refuses the whole batch if any entry is invalid, applies entries about one per second and stops at the first error, reporting what was and was not applied.
- Every applied change is written to
~/.gorelo-mcp-writes.jsonl.
gorelo_update_ticket
Sets the client or the status, and nothing else. Not the title, not the assignee, not the priority.
It exists for two recurring problems: a ticket with no client attached never appears in any client-scoped review, and a ticket parked in the wrong status makes a queue mean two things.
The endpoint accepts far more than this tool sends. The documented PATCH body includes
Title, LeadAssigneeId, AssistingAssigneeIds, WatcherIds, PriorityId, TypeId,
TagIds, GroupIds, ContactId, CcContactIds, AgentAssetIds, CustomAssetIds,
UptimeIds and BillingOverride. Sending only ClientId and StatusId is a deliberate
restriction — reassigning a ticket or rewriting its title from here would change someone
else’s queue without them watching it happen.
Changes are attributed. UpdatedByName is set (default "Gorelo MCP", overridable with
author). Unlike a comment, a status change leaves no visible content in the ticket — only a
history line — so an unattributed one is indistinguishable from a human doing it by hand.
Every change is verified. The tool reads the ticket, writes, reads it back, and confirms
the value actually moved — then reports before and after. That is not belt-and-braces: this API
ignores fields it does not recognise instead of rejecting them, exactly as it does with
query parameters, so a mistyped payload field returns 200 and changes nothing. Without the
read-back, a write that never happened is indistinguishable from one that did. When the check
fails the tool says so loudly, names the likely cause, and prints the payload it sent.
gorelo_set_uptime_maintenance
Starts or ends a maintenance window on one uptime check, and sends only
MaintenanceMode — never the target, type, client or tags. A check in maintenance raises no
alerts, so the risk here is silence, not damage:
startneeds a reason and a duration (1–10,080 minutes). A duration outside that range, both a duration andindefinite, or an action other thanstart/endis refused before sending. A duration of0means the window never ends, so it can only be sent withindefinite: true— never from a default, a missing value or a typo.- The PATCH has no “updated by” field, so the reason is prefixed
[Gorelo MCP]. - The check is read back, as
gorelo_update_ticketdoes, because this API accepts and ignores fields it doesn’t recognise. On astart, the read-back compares the reason as well as the duration, so a replacement window Gorelo accepted but ignored — leaving the old window’s duration and reason in place — is reported as failed, not verified. - If the PATCH itself fails in a way that doesn’t prove Gorelo said no — a timeout, a dropped
connection, a 5xx that outlasted the retries — the change is never reported as refused.
The check is read back anyway, the write is logged as
UNVERIFIED, and the reply says the change MAY have been applied, what the read-back shows, and whether that matches what was sent. Only an auth failure or a 4xx is reported as refused.
gorelo_list_uptime names every window that never expires or has run more than seven days.
A check is chosen by id, or by a fragment of its description or of its target (IP or URL) that matches exactly one check. Gorelo allows a check with no description at all; the list names such a check by its target and prints its id underneath, since a blank row can be neither read nor selected.
gorelo_create_draft_invoice
⚠ Paused since 3 October 2026. Gorelo’s
POST /v1/invoicesgives an API-created invoice the next number but does not advance the sequence, so the next invoice Gorelo raises itself reuses it. Seen on a live tenant: an API-created draft took 42110998 on 26 Sept and the recurring run issued 42110998 again on 29 Sept, to a different client - the only repeated number in 1,017 invoices. The tool now refuses every call, before any lookup, unlessGORELO_ALLOW_INVOICE_CREATE=true. Set that only once Gorelo has fixed the numbering.
Raises a manual invoice against one client, always as a Draft. StatusId is hard-coded to
1 and there is no parameter to change it: approving an invoice pushes it to Xero/QuickBooks,
and that stays a person’s decision, made in Gorelo.
- Each line names a catalogue item by id or exact name. A partial or ambiguous name is refused with the candidates — an invoice line never lands on a guessed product.
- Every line is checked before anything is sent, and every problem is reported at once.
- No
RecipientEmailsis ever sent. Cost, tax, account code and billable status come from the item. - An identical invoice within 24 hours is refused, using the same fingerprint log as comments.
The fingerprint is taken over normalised values, so
2and2.0are the same quantity. - The POST returns only an id, so the tool reads the invoice back with
GET /v1/invoices/{id}(new on 3 October 2026; before that it had to list the client’s recent invoices) — then reports the number, status, totals and line items as Gorelo computed them, with a bundle’s parts indented under it. If that read fails it says so loudly and does not retry.
A POST whose outcome is unknown is never retried — for invoices, comments and uploads alike. That covers a 5xx, a read timeout, a connection dropped or reset after the body went out (EOF, reset, broken pipe, TLS failure, write timeout), and a success status whose reply can’t be read. None of those says whether the write was applied, and retrying a POST that was applied raises a second invoice. The reply says it may exist, and the fingerprint is recorded so a repeat is refused. Only failures that happen before anything is sent — a refused connection, a connect timeout, a DNS failure — are reported as “nothing was created”. A 429 is still retried: a rate-limited request was never processed.
Attachments on gorelo_add_ticket_comment
files attaches files to a comment, uploading them first with POST /v1/attachments. Only
files in GORELO_ATTACH_DIR (default ~/gorelo-attachments) can be attached. Ticket text is
untrusted input the assistant reads; without a fixed folder, a crafted ticket could talk it
into attaching ~/.ssh/id_rsa or this project’s .env to a public comment. Paths are resolved
with realpath before the check, so .., ~ and symlinks can’t step outside. Each file
must be under Gorelo’s 44 MB limit, and every check runs before any upload.
Keep GORELO_ATTACH_DIR empty except while you are attaching something: the folder is
the whole boundary, so a crafted ticket can still get any file that happens to be sitting in
it attached to that ticket.
At most 10 files can be attached to one comment, with duplicate names collapsed before that count is checked, so passing the same file name twice doesn’t cost two of the ten.
If an upload or the comment fails after some files were uploaded, those files stay on the ticket with nothing referencing them — the API has no way to remove them — and the reply names them; a file whose own upload failed ambiguously (a 5xx, timeout, dropped connection or unreadable reply after the POST reached Gorelo) is instead named as one that “MAY have been uploaded,” since there’s no id back to confirm it landed.
There is no DELETE, anywhere
Gorelo now exposes DELETE for tickets, clients, contacts, agent assets, custom assets,
private comments and time entries — and, since 25 September 2026, invoices, contracts, items
and uptime checks. None of it is reachable from here, and the guarantee is structural rather
than a policy: Gorelo::Client has get, get_binary, post, post_multipart and patch
methods and no delete method at all. A tool cannot call a method that does not exist.
gorelo_api_probe is likewise GET-only — it has no method parameter to pass, which the test
suite asserts against the published tool schema.
Leave the write tools off until the read tools have earned their place.
Three things it does differently, and why
Status is judged on BaseStatusId, never on the status name. Gorelo files custom statuses
under one of five bases, and instances commonly put “Billing” or “Standing Ticket” under
the solved base. Those are real outstanding work. A server-side status filter silently
drops them, so filtering happens locally. Ask for status: "open" to get everything that
isn’t genuinely closed.
“Merged” is a status, not the IsMerged flag. IsMerged is false even on merged
tickets; they carry status id 5. Filtering on the flag leaves merged duplicates sitting in
your backlog looking like live work. They’re excluded, and the exclusion is stated.
Nothing is dropped silently. Every list states the full unfiltered count first, names what
it excluded, and warns when a ticket carries a status the API didn’t return from
/v1/tickets/statuses — an unfamiliar status can never quietly remove work from your view.
What the API can and can’t do
Confirmed working:
/v1/tickets GET, POST
/v1/tickets/{id} GET ← much richer than a list row; see below
/v1/tickets/{id}/comments GET, POST (oldest-first; ConversationType filter)
/v1/tickets/{id}/comments/{id} GET
/v1/tickets/{id}/conversations GET
/v1/attachments POST ← since 2026-09-25, multipart. /v1/tickets/{id}/attachments
is no longer in the spec
/v1/tickets/statuses | /tags | /types
/v1/clients Archived excluded by default; this server sends StatusIds=1,3,4 (since 2026-10-16)
/v1/contacts ClientId is SINGULAR here
/v1/assets/agents ClientIds filter since 2026-08-21
/v1/assets/custom since 2026-08-21
/v1/organization/users
/v1/time-entries GET ← since 2026-09-04. Tenant-wide, cursor-paginated.
Filters documented 2026-09-25 - see below
/v1/contracts GET ← since 2026-09-04. (a "Contract Group" on UI screens before 3 Oct)
/v1/billing-roles GET ← since 2026-09-04. Small, unpaginated
/v1/work-types GET ← since 2026-09-04. Small, unpaginated
/v1/invoices GET, POST ← since 2026-09-25. POST used for Drafts only
/v1/invoices/{id} GET ← since 2026-10-03. Line items (bundle parts in SubItems) and attachments
/v1/invoices/{id}/pdf GET ← since 2026-09-25. A file, not the envelope
/v1/contracts/{id} GET ← since 2026-09-25. Service lines with line items
/v1/items | /{id} | /categories GET ← since 2026-09-25
/v1/taxes GET ← since 2026-09-25. Small, unpaginated
/v1/uptime | /{id} GET, PATCH (MaintenanceMode only) ← since 2026-09-25
GET /v1/time-entries/statuses is a 404. It looks like it ought to exist, by analogy
with /v1/tickets/statuses. It does not. Don’t invent it.
The 4 September 2026 release
This release invalidated a claim this README made for three weeks. Four endpoints
appeared, all returning HTTP 200 with the usual
{StatusCode, IsSuccess, Data, DataContext, Notifications} envelope and the same
DataContext.Pagination.NextCursor scheme everything else uses:
| Endpoint | Paginated | What it holds |
|---|---|---|
GET /v1/time-entries |
yes, cursor | One row per logged entry, tenant-wide |
GET /v1/contracts |
yes, cursor | Recurring agreements (older UI screens: Contract Groups) |
GET /v1/billing-roles |
no | Id, Name, HourlyRate, CoaCode, Tax |
GET /v1/work-types |
no | Id, Name, HourlyMultiplier, IsDefaultOutsideBusinessHours, BillableStatus, CoaCode, Tax, MinimumTimeInMinutes |
A time entry carries:
Id, Ticket {Id, Number, Title}, Task, User {Id, Name},
StartedOn, EndedOn, ActualHours, AdjustedHours,
BillableStatus {Id, Name}, BillingRole {Id, Name}, WorkType {Id, Name},
ServiceLine {Id, Name}, Comment, Distance, Attachments,
CreatedOn, UpdatedOn
What this changed here. gorelo_time_report used to page /v1/tickets, fetch each
ticket’s time summary with one extra request per ticket, and book every hour to that
ticket’s lead assignee — so an assisting technician’s time was reported against
somebody else. It said so on every run, and the caveat was honest. It is now one paged
sweep of /v1/time-entries, grouped by the User on each entry, so per-technician
totals are exact and assisting time lands on whoever did it. The warning has been
deleted along with the behaviour that made it necessary.
An entry has no client. It names its Ticket and nothing else. Filtering by client
is now done by the API with ClientIds (see the 25 September release below), but
grouping entries by client across several clients still needs a ticket-to-client
lookup. This server builds one with a single paged sweep of /v1/tickets — scoped with
ClientIds to just the matched clients’ tickets when a client filter was given, otherwise
the whole tenant, cached for the life of the process — and says so in the reply. Never a
fetch per entry, which is the N+1 the new endpoint exists to remove.
The 25 September 2026 release
It settled the one thing the 4 September section left open, and exposed a bug.
The window filter is documented now. This README used to say the window parameter on
/v1/time-entries was unverified, and the server guessed CreatedSince, padded it, and
hedged every reply. The spec now documents StartedSince, StartedBefore,
CreatedSince/Before, UpdatedSince/Before, and the id filters ClientIds,
LocationIds, TicketIds, TaskIds, UserIds and InvoiceIds. The server now sends
StartedSince, UserIds, ClientIds and TicketIds. Checked against a live tenant: a
7-day report is one request and nothing from before the window comes back. Because the
API silently ignores names it doesn’t recognise, the window, UserIds and TicketIds are
still re-checked locally on every entry, as a safety net: a row that fails is dropped, and
the reply names the filter that wasn’t honoured. ClientIds can’t be re-checked — an entry
carries no client — so it is trusted.
AdjustedHours can be null, and null does not mean zero. The spec now says
AdjustedHours is null when no rounding applied, and the billed duration is then
ActualHours. This server read the null as 0, so every unrounded entry vanished from
“to invoice” and “billable”, and realisation came out low. It is fixed. On the tenant it
was checked against, every work type rounds, so no entry in the previous 30 days was
affected — but a tenant, or a work type, without rounding would have been.
The same release added invoices, the item catalogue, taxes, full contract detail, uptime checks and a general attachment upload, and the tools above use all of them. Three things about them are easy to miss:
- Invoice numbers are not unique. On the live tenant one number belonged to two
clients’ invoices.
gorelo_get_invoice_pdfrefuses a number that matches more than one invoice and lists each with its id and client; download by id.gorelo_list_invoicesshows every match and says so. - Downloading an invoice PDF is not a pure read. Gorelo records each download against the invoice as an export event.
PageSizeoutside 1–200 is a 400 on/v1/invoices,/v1/itemsand/v1/uptime, not clamped as on the older endpoints.- Subcategory ids are not unique. They are issued independently of category ids, so one number can name a subcategory under two categories. Look one up only inside its own category.
A fourth wrinkle sits in gorelo_list_items rather than the API itself: when a bundle’s part
has no cost or price, the tool prints the bundle’s sum-of-parts as “unknown” rather than
silently counting the missing part as zero, which would understate the total without saying so.
The 3 October 2026 release
One of these broke things silently; the rest are renames and new capabilities.
BaseStatusIdbecameBaseStatus {Id, Name}. A ticket’s status class is now an object. This server read the old field, got nothing, and so could no longer tell closed tickets from open ones: “open” returned closed tickets as well — 664 against 78 live. Fixed in8445efa, which readsBaseStatus.Id. This is the same failure shape as the 2026-08-21 renames: nothing errors, the numbers are just wrong.- The web UI was renamed to match the API. Contract Group → Contract, Contract → Service line, Contract Type → Labor terms, Per Hour → Hourly, Automatically Covered → Coverage. The API’s own words did not change. See the contracts note.
GET /v1/invoices/{invoiceId}exists. It returns the invoice plusLineItems[](a bundle is one line with its parts inSubItems) andAttachments[].gorelo_create_draft_invoicenow reads the new draft back with it, andgorelo_list_invoiceswithnumbershows the line items. The old “list the client’s recent invoices” read-back is gone.GET /v1/clientsexcludes inactive clients by default.StatusIds=1,2returns both (173 active + 3 inactive = 176 on the tenant checked). The server now always asks for both, so an inactive client’s tickets still have a name, and marks such clients(inactive). Superseded on 16 October - see below: status 2 no longer exists.- Time-entry billable status names changed:
Billable,No charge,Non-billable(wereBillable,No Charge,Not Billable). The server decides billable by the status name starting with “billable”, soNon-billableis correctly not billable; the suite pins that. - API keys can be limited per module to Read, Write or Delete — see key scopes.
- Contract dates are now calendar dates rather than timestamps. No code change was needed: the server already shows only the first ten characters.
ClientId/LocationIdarenullinstead of-1for unassigned agents and uptime checks. No code change was needed.
The 16 October 2026 release
It broke every client lookup, and the release notes did not say so. Client statuses are
now 1 Active, 3 Credit hold, 4 Archived; Archived is excluded unless asked for, and any
other id is a 400 - including 2, the “inactive” this server had been sending since the
3 October release. Every tool that resolves a client name failed with
HTTP 400 … Status id 2 is invalid until the server switched to StatusIds=1,3,4. Archived
clients are labelled (archived) and, like inactive ones before, a write tool reaches one only
by numeric id; clients on credit hold are labelled (credit hold) and can still be targeted.
It was caught by a live smoke run of the read tools after reading the release notes - the
notes only mention CustomProperties leaving the client list.
The rest of the release does not touch this server: CustomProperties left the client and
agent lists (never read here); project-comment BodyText was removed (we read BodyText on
ticket comments, which is unchanged); POST /v1/attachments gained Document (25 MB),
while ticket uploads stay at 44 MB; Documents endpoints are new.
Invoice numbering: the four API-created test invoices of 3 October (42111044–47) were
followed by Gorelo’s own run issuing 42111048 on 5 October, and no number has repeated since.
The bug seen on 26–29 September did not recur. gorelo_create_draft_invoice stays paused
until Gorelo confirms a fix.
Contracts: the API and the UI now use the same words
Until 3 October 2026 the UI used different words from the API for the same objects:
| In the API | In Gorelo’s web UI until 3 Oct 2026 | Now |
|---|---|---|
a contract (/v1/contracts) |
a Contract Group — the invoice | Contract |
a ServiceLine inside it |
a Contract | Service line |
That mismatch was real and made correct data look wrong, which is why the tools used to print both vocabularies. It is gone: the UI was renamed to match the API. The contract tools now print a single line saying so, for anyone holding an old screenshot or export.
A contract with no service lines is flagged: it is an invoice container with nothing on it, and from outside it looks identical to a healthy one.
The 2026-08-21 release
Three fields were renamed, and every one of them fails silently. A renamed field does not error — it simply stops appearing, so a flag stops showing and a date vanishes from a report with nothing to say it happened. This server reads both names so it works against a tenant on either version:
| Endpoint | Was | Now |
|---|---|---|
/v1/tickets |
IsAwaitingClient |
IsWaitingOnThem |
/v1/tickets |
SLA minutes | Sla.FirstResponse.ElapsedBusinessMinutes |
/v1/assets/agents |
ClientLocationId |
LocationId |
/v1/assets/agents |
WarrantyExpiryDate |
WarrantyStartDate + WarrantyEndDate |
/v1/contacts |
ClientLocationId |
LocationId |
GET /v1/tickets/{id} now answers billing questions the API previously could not. A list
row does not carry any of this — only the get-by-id does, so a tool that wants it must fetch
the detail even when it already has the ticket:
Time.ActualHours what was recorded
Time.AdjustedHours what will be invoiced
Time.Breakdown.Billable / .NotBillable / .NotBillableHidden
Products.Count / .TotalAmount
BillingOverride, Shipments, Description, AgentAssetIds, Banner
The distinction that matters for a backlog review: a ticket sitting in Billing with
ActualHours of 0 was never time-recorded, which is a different problem — and a different
fix — from one with hours that simply have not been invoiced yet. gorelo_get_ticket flags
both.
Filtering arrived on clients, contacts and agent assets: StatusIds, ClientIds, keyword
Query, and created/updated date ranges. gorelo_list_assets used to page the entire fleet —
626 devices on a real tenant — to show four; it now sends ClientIds. Verified applied rather
than ignored by the FilterHash trick below.
⚠️ DELETE endpoints now exist for agent assets, custom assets, clients, contacts, tickets
and private comments. This server never sends DELETE, and gorelo_api_probe is GET-only —
both asserted in the test suite so it stays that way. Blocked deletes return 409 naming what is
in the way.
Gorelo publishes a full OpenAPI spec — read it before guessing at parameter names:
- Swagger UI:
https://api.aue.gorelo.io/swagger(AU) ·https://api.usw.gorelo.io/swagger(US) - Spec JSON:
https://api.aue.gorelo.io/swagger/v1/swagger.json(no API key needed). Appending/v1/swagger.jsonto the base URL, as this README used to say, is a 404. - Docs index built for LLMs: help.gorelo.io/llms.txt
GET /v1/tickets documents: Query, StatusIds, ClientIds, PriorityIds, TypeIds,
LeadAssigneeIds, ContactIds, TagIds, GroupIds, UpdatedSince, UpdatedBefore,
CreatedSince, CreatedBefore, SortBy, SortOrder, PageSize, Cursor.
⚠️ Unknown query parameters are ignored, not rejected. A misspelled or invented filter returns HTTP 200 and the full unfiltered set, which looks exactly like a working call. Check the spec rather than trying names.
Three consequences worth knowing:
There is no assisting-assignee filter. LeadAssigneeIds is the only assignment filter in
the spec; AssistingAssigneeIds and WatcherIds are response fields only. Gorelo’s own UI
counts you as lead or assisting, so this server sweeps the organisation’s unclosed tickets to
match — usually one page, one request. Pass include_assisting: false to skip it.
Query searches title, number and display number (200 characters max), which is how a
ticket number is resolved to the GUID that /v1/tickets/{id} needs.
Deleted comments are still returned by the API, with their body intact. The comment schema contains no deletion, removal or visibility property at all, so a consumer cannot tell — while the web UI correctly shows “This comment has been deleted”. Reported to Gorelo. This server withholds the body of any comment carrying a deletion flag, so it will do the right thing if one ever appears; until then, treat comment history as potentially including retracted content, and never rely on deleting a comment to remove sensitive data.
Two tricks worth stealing
405 versus 404 maps the surface. A 404 means no route of any verb matches that path. A
405 means the route exists and does not accept this one. So a plain GET against a
candidate path is a reliable existence test, even for write-only endpoints this server would
never call — which is how /v1/tickets/{id}/time-entries/{id} was confirmed to be a
DELETE-only route after two wrongly-spelled guesses had returned 404. gorelo_api_probe
explains any 405 it gets rather than reporting it as a dead end.
⚠️ But a 405 tells you about one path and one verb — never about a feature. That DELETE-only route was read here as proof that time entries could not be read at all, which it never was; the tenant-wide collection simply hadn’t shipped yet. Map paths this way, not capabilities.
DataContext.Pagination.TotalCount comes back on every query, so counting anything costs
one request with PageSize=1.
The pagination cursor base64-decodes to JSON containing a FilterHash. If a query’s hash
matches the unfiltered query’s hash, the parameter you passed was ignored. That is how the
list above was established, in a handful of calls rather than by guessing.
StatusReason is the most useful field in the API
It’s stored as JSON, not text:
{"reason":"waiting on the replacement part","updatedById":42,"updatedOn":"..."}
Every status with AskForReason set collects one. On a stalled ticket it’s the note saying
why it stopped, which is usually the only thing you need to act. Pass
include_reasons: true to gorelo_list_tickets and a whole queue explains itself in one call.
Tests
test/ holds a self-contained fake Gorelo — no live tenant, no real data — and a driver that
speaks genuine JSON-RPC to the server over stdio.
python3 test/mock_gorelo.py # in one terminal
python3 test/drive.py # in another
269 assertions covering the cases that have actually broken: merged tickets, unlisted statuses,
assisting assignees, watcher-only exclusion, closed-ticket exclusion, lookup by number,
deleted comments, cursor pagination, rate-limit retry, every write guard, and the protocol
edge cases (unknown method, unknown tool, malformed input). The driver sets
GORELO_READ_TIMEOUT low so a read timeout on a POST can be reproduced in well under a
second instead of waiting on the real 60-second default.
The fake tenant carries 225 time entries — enough to force a second cursor page — including
two technicians logging time on one ticket, which is precisely the case the old
lead-assignee attribution got wrong. One client is held back from the bulk generator so a
per-client total is exactly predictable, which is what proves the ticket-to-client
resolution works at all: an entry carries no client of its own. One of that client’s
entries has a null AdjustedHours, which must invoice at its ActualHours. The fake
/v1/time-entries honours the filters the spec documents and ignores every other name,
as the real API does, and it records the query it last received, so the suite asserts
which parameter names were actually sent. /v1/time-entries/statuses answers 404,
because it does.
python3 test/drive.py somefile.json runs an arbitrary list of tool calls instead, which is
the quick way to eyeball output while changing a tool.
The mock deliberately reproduces the API’s awkward parts — the ignored parameters, the missing
Merged status, cursor pagination, 429 with retry_after. Testing against a polite fake
would have caught none of the real bugs.
Extras
scripts/whose-tickets.rb prints your unclosed tickets split into lead / assisting /
watcher, by scanning the whole ticket table. Use it to reconcile against the number Gorelo’s
own UI shows you.
Notes
Why no MCP gem. The stdio surface of MCP is five methods, so lib/mcp_server.rb
implements them directly in about 150 lines. That means no Bundler, no gem install, and no
dependency that can break the one tool you actually use. lib/gorelo_tools.rb knows nothing
about the transport, so swapping in the official
Ruby SDK later — for HTTP, OAuth or
resources — leaves the tools unchanged.
Windows encoding. Output is written in binary mode so \n is never rewritten to \r\n,
which would corrupt the line framing, and JSON is generated ascii_only so the bytes on the
wire are pure ASCII whatever the machine’s code page.
Still not observed through the API: invoices. Contracts and time entries are readable as of 4 September 2026 — see above.
Confirmed endpoints (this list is what has been observed, not a claim to completeness — see the warning below):
/v1/alerts (POST) /v1/organization/users
/v1/assets/agents {id} /v1/organization/groups
/v1/assets/custom {id} /v1/tickets
/v1/clients {id} /v1/tickets/{ticketId} GET PATCH DELETE
/v1/clients/{id}/locations /v1/contacts {id}
/v1/time-entries (2026-09-04) /v1/contracts (2026-09-04)
/v1/billing-roles (2026-09-04) /v1/work-types (2026-09-04)
/v1/tickets/{id}/time-entries/{id} DELETE only — see below
Time entries: what was true until 4 September 2026, and what is true now
This section used to say time entries could not be read. That is no longer correct, and the reasoning that led there is worth keeping as a warning.
The evidence at the time was real: /v1/tickets/{ticketId}/time-entries/{id} answers 405
to a GET, so the route exists for another verb; the collection path
/v1/tickets/{id}/time-entries answered 404; /v1/time-entries/{id} answered 404;
and the ticket detail carried Time totals with no entry ids. The conclusion drawn — that
Gorelo shipped a way to delete a time entry and no way to read one — followed from those
four observations and was stated here as fact.
What was actually being measured was four paths on one day. On 4 September 2026 Gorelo
shipped the tenant-wide collection GET /v1/time-entries, which returns every entry with its
User, hours, BillableStatus, work type, billing role and comment. The per-ticket sub-route
is still DELETE-only, and this server still never sends DELETE — but “one path is write-only”
was never the same claim as “this data cannot be read”, and conflating them cost this README
three weeks of telling other people something untrue.
gorelo_time_report no longer attributes hours to a lead assignee, because it no longer has
to guess who did the work: the entry says. Realisation is now reported as its own ratio
(invoiceable ÷ recorded hours — what survived rounding and write-downs, where a null
AdjustedHours invoices at ActualHours) with the billable
share as a separate column, rather than the two being folded into one number.
⚠️ Do not treat any endpoint list as complete, including this one. The published
swagger.json is large enough that fetching it through a summarising tool silently truncates,
and a truncated list reads exactly like a short one. The time-entry route above was missed
precisely that way — declared absent on the strength of a partial read of the spec plus two
404s on the wrong path spellings.
⚠️ Note /v1/tickets/{ticketId} now accepts PATCH and DELETE, and clients and contacts
accept POST/PATCH/DELETE. This server issues none of them.
Install
This server does not publish a one-line install command.
Open the repository installation guideConfiguration
{
"mcpServers": {
"gorelo": {
"command": "wsl.exe",
"args": ["-d", "Ubuntu", "-e", "/usr/bin/ruby",
"/home/YOURNAME/gorelo-mcp/gorelo-mcp-server.rb"]
}
}
}