
Security News
GPT-6 Astra Attempts Supply Chain Attacks Against Open Source Maintainers in Testing
GPT-6 Astra hits 100% on ExploitBench and finds zero-days autonomously, while independent tests reveal scope violations and monitoring gaps.
zammad-remote-mcp
Advanced tools
Stateless remote MCP server for Zammad — full ticket operations, an intelligent search layer and OAuth 2.1, running on Node.js or Cloudflare Workers from one codebase.
A remote MCP server for Zammad, built on Hono and deployable to Node.js or Cloudflare Workers from one codebase.
src/core, which imports no Node built-ins. The two
hosts differ only in where the environment comes from and how the app is served.Everything lives in zammad-remote-mcp. The source keeps the runtimes apart, but there is a single
version and a single publish.
| Import | What it is |
|---|---|
zammad-remote-mcp | the runtime-agnostic core: Hono app, MCP server, 32 tools, Zammad client, search builder, OAuth proxy. Uses only WebCrypto, fetch, TextEncoder and atob/btoa. |
zammad-remote-mcp/node | Node host: .env loading, socket binding, signal handling |
examples/cloudflare | a deployable Workers host, ~60 lines, consuming the package like any other dependency |
npx zammad-remote-mcp | the CLI — the Node host with a shebang |
Both hosts call the same bootstrap({ env, cache }) and serve the same fetch handler. Anything
runtime-specific that could not be avoided — reading a file from disk, binding a port — lives in
src/node or src/cloudflare and nowhere else.
npm install && npm run build
Register an application in Zammad's admin panel under System → API → Applications → New. Set
Callback URL to <PUBLIC_URL>/oauth/callback (the form takes one URI per line). Zammad shows the
client ID and secret afterwards. Then:
cp .env.example .env
.env is read from the working directory at startup via Node's built-in process.loadEnvFile — no
dependency, and real environment variables take precedence over the file, so PORT=3999 npm start
overrides it and container/systemd environments stay authoritative. Point ENV_FILE at another path
to use a different one (a missing ENV_FILE is an error; a missing .env is not).
npm start
The MCP endpoint is <PUBLIC_URL>/mcp. Point an MCP client at it and it will discover the
authorization server on its own.
cp .dev.vars.example .dev.vars # fill in the secrets
npm run dev:cf
Edit vars in wrangler.jsonc — at minimum ZAMMAD_URL and
PUBLIC_URL, which must match the URL clients actually dial. Then push the secrets and deploy:
npx wrangler secret put ZAMMAD_OAUTH_CLIENT_ID
npx wrangler secret put ZAMMAD_OAUTH_CLIENT_SECRET
npx wrangler secret put OAUTH_STATE_SECRET
npm run deploy
OAUTH_STATE_SECRET must be stable across deployments — rotating it invalidates registered clients
and in-flight logins. Generate one with openssl rand -base64 48.
The button at the top of this README clones the repository into your own Cloudflare account,
provisions the KV namespace for the lookup cache, prompts for the secrets listed in
.dev.vars.example, and deploys. The id committed in wrangler.jsonc is a placeholder and is
rewritten with the newly created namespace.
You still have to set ZAMMAD_URL and PUBLIC_URL afterwards — PUBLIC_URL cannot be known before
the Worker has a hostname, and it must match what clients dial, since it forms the OAuth issuer and
the callback URL. Set both under the Worker's Settings → Variables, then redeploy.
docker build -t zammad-remote-mcp .
docker run -p 3000:3000 \
-e ZAMMAD_URL=https://support.example.com \
-e ZAMMAD_AUTH_MODE=token \
-e ZAMMAD_API_TOKEN=your-token \
zammad-remote-mcp
Or with Compose, which reads the same variables from a local .env:
docker compose up --build
The image is a multi-stage build: TypeScript is compiled with the full dependency tree, the runtime
stage keeps only production dependencies, and the process runs as the unprivileged node user. A
HEALTHCHECK polls /health using Node's global fetch, so no curl has to be installed.
docker stop is a graceful shutdown, not a timeout: CMD uses the exec form, so Node runs as PID 1
and receives SIGTERM directly, and the server closes its listener on it.
Point Coolify at this repository and choose Dockerfile as the build pack — it will find
/Dockerfile without further configuration. Then:
ZAMMAD_URL, and either ZAMMAD_API_TOKEN for token
mode or the three OAuth values). Everything in .env.example is accepted.PUBLIC_URL to the public URL Coolify assigns, not the container address. It forms the
OAuth issuer, the callback URL and the resource identifier, and a wrong value breaks the
authorization flow in a way that points nowhere near the cause — the server logs a warning when
it does not match the host a request arrived on.3000, or set PORT and match it in Coolify's port mapping./health as the health check path.Then register <PUBLIC_URL>/oauth/callback in Zammad under System → API → Applications.
OAUTH_STATE_SECRET must be stable across restarts and identical on every replica — it signs the
client_id and the OAuth state, which is what lets the proxy stay stateless. Generate it once with
openssl rand -base64 48 and store it as a Coolify secret.
| Node | Workers | |
|---|---|---|
| Config source | process.env (+ .env file) | env binding (vars + secrets) |
| Lookup cache | one long-lived process, high hit rate | per isolate, so more Zammad calls |
| OAuth endpoint rate limiting | effective (in-process store) | per isolate, so largely ineffective — use Cloudflare Rate Limiting in front |
| Everything else | identical | identical |
Skip OAuth entirely. Create a personal token in Zammad under avatar → Profile → Token Access
(needs the ticket.agent permission), then:
ZAMMAD_URL=https://support.example.com ZAMMAD_AUTH_MODE=token ZAMMAD_API_TOKEN=your-token PORT=3000 npm run dev
In this mode the MCP endpoint requires no credential of its own — anyone who can reach the port acts as that token's user. Keep it bound to localhost and never expose it.
Check it is alive:
curl -s http://localhost:3000/health
Drive it with raw JSON-RPC. Note the Accept header — the MCP spec requires both types:
curl -s -X POST http://localhost:3000/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
Because the server is stateless there is no session to carry — every call stands alone, so you can go straight to a tool without repeating the handshake:
curl -s -X POST http://localhost:3000/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"zammad_get_user","arguments":{"user":"me"}}}'
A search, with the generated selector echoed back under search:
curl -s -X POST http://localhost:3000/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"zammad_search_tickets","arguments":{"state":["open"],"updated_at":{"more_than_ago":"7d"},"output":"summary"}}}'
The UI opens a browser and proxies to the server; choose Streamable HTTP as the transport and
enter http://localhost:3000/mcp:
npx @modelcontextprotocol/inspector
There is also a scriptable CLI mode, which is usually quicker. Note the target URL is a positional
argument — --server-url is only for the UI's own config:
npx @modelcontextprotocol/inspector --cli http://localhost:3000/mcp --transport http --method tools/list
Call a tool. Arguments are repeated --tool-arg key=value pairs; single values are accepted wherever
the schema takes an array, so state=open works:
npx @modelcontextprotocol/inspector --cli http://localhost:3000/mcp --transport http --method tools/call --tool-name zammad_search_tickets --tool-arg state=open --tool-arg output=count
For nested arguments (tags, created_at, article, …) --tool-arg is too flat — use the curl form
above with a JSON body instead.
If the server runs with ZAMMAD_AUTH_MODE=oauth, pass the token through:
npx @modelcontextprotocol/inspector --cli http://localhost:3000/mcp --transport http --header "Authorization: Bearer YOUR_ZAMMAD_TOKEN" --method tools/list
claude mcp add --transport http zammad-local http://localhost:3000/mcp
There is a real obstacle here, so it is worth stating up front. Zammad leaves Doorkeeper's
force_ssl_in_redirect_uri at its default, which is true outside Rails development mode, and
Doorkeeper's redirect validator has no loopback exemption. A production Zammad will therefore
reject http://localhost:3000/oauth/callback when you try to register the application. Three ways
around it:
cloudflared tunnel --url http://localhost:3000, ngrok http 3000) or a local TLS reverse proxy (Caddy). Register that
HTTPS callback in Zammad and set PUBLIC_URL to match — it must be the URL clients actually dial,
since it forms the issuer and the callback.false and plain
http://localhost callbacks are accepted.The OAuth plumbing can still be exercised on plain localhost without touching Zammad, because the metadata and registration endpoints are served locally:
ZAMMAD_URL=https://support.example.com ZAMMAD_AUTH_MODE=oauth ZAMMAD_OAUTH_MODE=proxy ZAMMAD_OAUTH_CLIENT_ID=x ZAMMAD_OAUTH_CLIENT_SECRET=y OAUTH_STATE_SECRET=local-dev-secret-at-least-16-chars PUBLIC_URL=http://localhost:3000 PORT=3000 npm run dev
curl -s http://localhost:3000/.well-known/oauth-protected-resource/mcp
An unauthenticated call returns a 401 whose WWW-Authenticate header points at that metadata —
which is exactly how a client discovers where to authorize:
curl -si -X POST http://localhost:3000/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep -i www-authenticate
Once you hold a Zammad access token, pass it directly and skip the browser flow:
curl -s -X POST http://localhost:3000/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -H 'Authorization: Bearer YOUR_ZAMMAD_TOKEN' -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"zammad_get_user","arguments":{"user":"me"}}}'
test/server.test.ts runs the real Hono app against a stub Zammad over real HTTP, covering the OAuth
registration/authorize/callback round trip and the MCP handshake. npm test needs no network and no
Zammad instance.
Yes — fully. Nothing is retained between HTTP requests, so any replica can serve any request and scaling out needs no shared store, no sticky sessions and no session affinity at the load balancer.
Three things make that work:
1. The transport runs in stateless mode. StreamableHTTPTransport is constructed with
sessionIdGenerator: undefined, so no Mcp-Session-Id is ever issued or expected. Each POST gets a
fresh McpServer and transport, both discarded once the response is written. enableJsonResponse
returns a complete JSON body rather than an SSE stream, which is what makes that teardown safe.
2. Credentials are never stored. In the default oauth mode the MCP client sends its Zammad
access token on every request, and the server forwards it to the Zammad API unchanged. Zammad is the
authorization server and the source of truth for identity; this server holds no token database and
no refresh-token bookkeeping. It also means Zammad's own permission model applies unchanged — an
agent sees agent tickets, a customer sees their own.
3. The OAuth proxy carries its state in signed parameters. Doorkeeper has no dynamic client registration and a Zammad application is pinned to fixed redirect URIs, which does not fit MCP clients that self-register on an ephemeral localhost port. Instead of a client database:
client_id of the form zmcp_<payload>.<hmac>, where the payload is the
registration record (the client's redirect URIs and name). getClient verifies and decodes it./authorize swaps the client's redirect URI for this server's single /oauth/callback — the one
URI registered in Zammad — and packs the original URI plus the client's state into an
HMAC-signed state./oauth/callback verifies that signature and bounces the code back to the client's own URI./token substitutes the real Zammad client credentials.PKCE flows through untouched: the client's code_challenge reaches Doorkeeper directly and its
code_verifier is forwarded on exchange, so the proxy is never trusted with proof of possession.
All replicas need is the same OAUTH_STATE_SECRET.
There is an in-process cache (METADATA_CACHE_TTL_SECONDS) for slow-moving lookups — groups,
states, priorities, resolved user and organization IDs. It is per-instance, per-credential and
TTL-bounded, and no request depends on a cache another request populated: every entry is rebuilt
from Zammad on miss. It is a latency optimisation, not state. Set it to 0 to disable it entirely
and the behaviour is identical, just chattier.
Server-initiated messages are out of scope: no resource subscriptions, no server-driven sampling or elicitation, no mid-tool progress notifications. For a tool-only helpdesk server none of that is needed, and the operational simplicity is worth far more.
ZAMMAD_AUTH_MODE selects how the server talks to the Zammad REST API. All three are the schemes
Zammad documents in its API introduction.
| Mode | Header sent to Zammad | Use it for |
|---|---|---|
oauth (default) | Authorization: Bearer <token> | Multi-user deployments. Each MCP client authorizes itself; the server stores nothing. |
token | Authorization: Token token=<token> | Single-tenant automation, local development. |
basic | Authorization: Basic <base64> | Legacy instances only — Zammad advises against it. |
ZAMMAD_OAUTH_MODE then selects how MCP clients authorize. It is a sub-setting of
ZAMMAD_AUTH_MODE, not an independent axis: only oauth reads the caller's bearer token, so only
there is there anything for the OAuth endpoints to be for. Leave it unset and it follows —
proxy under oauth, disabled under token/basic — so token mode needs no OAuth settings at
all. Combining token/basic with proxy or passthrough is rejected at startup rather than
silently issuing tokens the request path would discard.
| Mode | Behaviour |
|---|---|
proxy (default under oauth) | This server is the authorization server from the client's perspective and proxies to Zammad. Supports dynamic client registration and ephemeral redirect URIs. One callback URL to register in Zammad. |
passthrough | Clients talk to Zammad directly. Every client redirect URI must be registered in Zammad by hand, and Doorkeeper publishes no authorization-server metadata, so this server publishes it on Zammad's behalf. Brittle with clients that self-register. |
disabled (automatic under token/basic) | No OAuth metadata is served. Worth setting explicitly under oauth only when an API gateway in front already handles discovery and just forwards the bearer token. |
Tools accept an optional on_behalf_of argument, which maps to Zammad's X-On-Behalf-Of header for
impersonation (requires admin rights).
zammad_search_tickets is the centrepiece. Rather than exposing Zammad's raw query parameter, it
takes structured filters and compiles them.
Reading Zammad's CanSearch#search shows two facts that shape everything:
query parameter is non-blank. With a blank
query it runs the SQL search — even on an instance that has Elasticsearch installed.condition selector is honoured on both paths (Selector::Sql for SQL,
Selector::SearchIndex for Elasticsearch).So the default auto strategy puts genuine free text in query, where it becomes a full-text query
with Elasticsearch and degrades to a LIKE over title/number/article fields without it, and puts
every structured filter in condition, where it is exact and behaves identically either way. The
server therefore does not need to know whether Elasticsearch is installed.
strategy | query | condition | Needs Elasticsearch |
|---|---|---|---|
auto (default) | free text only | all filters | no |
fulltext | everything, as one Lucene query_string | – | yes |
structured | (none) | all filters | no — forces the exact DB search |
state: ["open"], priority: ["3 high"], group: ["1st Level"],
owner: ["jane@acme.com"] are resolved to the numeric IDs selectors filter on. A wrong value comes
back as an error listing the valid options, which the model can act on.owner: ["me"], customer: ["me"] and organization: ["mine"] compile to
Zammad pre_conditions (current_user.id, current_user.organization_id) rather than a baked-in
ID — cheaper and correct for whoever is authenticated.state_type. Filter by category (open, pending reminder, closed, …) and it expands to
every matching state ID, so it keeps working on instances with custom states.match: "any" ORs the positive filters, while *_not exclusions stay ANDed
on top — otherwise "any of these states, but never closed" would match closed tickets.created_at: { within_last: "7d" }, { more_than_ago: "30d" },
{ between: ["2026-01-01", "2026-02-01"] }, { today: true } — compiled to Zammad's
(relative) / in range operators, or to Elasticsearch date math in fulltext mode.in range gets a two-element array; article types and senders map to
their seeded primary keys.custom for Object Manager attributes, raw_condition for a hand-written
selector, raw_query for raw Lucene.query and condition under search, so a
query that returns too much or too little can be corrected directly.// "high-priority tickets in 1st Level that I own, untouched for a fortnight,
// tagged vip, excluding anything closed"
{
"owner": ["me"],
"group": ["1st Level"],
"priority": ["3 high"],
"state_not": ["closed", "merged"],
"updated_at": { "more_than_ago": "14d" },
"tags": { "any": ["vip"] },
"sort_by": "updated_at",
"order_by": "asc",
"output": "summary"
}
Start with output: "count" to size a result set before paging through it.
The states, priorities, groups and macros of the connected instance are read at tools/list time and
folded into the tool schemas as enums. A model reading zammad_search_tickets sees
"state": {"enum": ["new", "open", "pending reminder", "closed", "merged", "pending close"]} for
that Zammad, so it never has to call a discovery tool first, and never has to guess a state name.
That replaced four tools — zammad_list_ticket_states, zammad_list_ticket_priorities,
zammad_list_groups and zammad_list_macros are gone, and zammad_apply_macro now takes a macro
name instead of a numeric ID.
Three properties keep this from becoming brittle:
enum | string | number, so a state created after a
client cached the tool list is still accepted. Resolution stays in LookupService, which returns
the valid options when a value really is wrong.tools/list keeps working, which matters because it is how a client
discovers anything at all. Set DYNAMIC_TOOL_SCHEMAS=false to publish static schemas and have
tools/list need no Zammad contact.SCHEMA_ENUM_MAX_VALUES (default 150) the enum is dropped rather than
truncated: a partial list looks authoritative while silently hiding valid values.Not everything is a closed set, and with an agent-level token — the sensible default for this server — Zammad withholds some catalogues entirely:
| Enumerable? | Why | |
|---|---|---|
| States, priorities, groups, macros | ✅ in the schema | Small, closed, readable by agents |
| Tags | ✅ in the schema, advisory | /api/v1/tag_list is admin-only, but tag_search with an empty term and an explicit limit is agent-readable and complete — it caps at ten without one. Unioned with a free string, because a tag can be created by using it |
| Article types and senders | ✅ static enum | Seeded in Zammad with fixed primary keys |
| Users, customers, organizations | ❌ | Unbounded |
| Object Manager attributes | ❌ | /api/v1/object_manager_attributes requires admin.object_manager; an agent token gets 403, so neither the model nor this server can enumerate custom fields |
Search — zammad_search_tickets, zammad_search_users, zammad_search_organizations
Tickets — zammad_get_ticket, zammad_list_tickets, zammad_create_ticket,
zammad_update_ticket,
zammad_delete_ticket, zammad_merge_tickets, zammad_mass_update_tickets, zammad_apply_macro,
zammad_get_ticket_history, zammad_get_related_tickets, zammad_get_recent_tickets
Articles — zammad_list_ticket_articles, zammad_get_article, zammad_create_article,
zammad_update_article, zammad_delete_article, zammad_get_article_plain,
zammad_download_attachment
Tags, links, time — zammad_link_tickets, zammad_unlink_tickets, zammad_list_ticket_links,
zammad_list_time_accounting, zammad_create_time_accounting
Discovery — zammad_get_user, zammad_get_organization,
zammad_list_overviews, zammad_list_custom_attributes, zammad_get_group_signature,
zammad_refresh_metadata_cache
Write tools default to the safe option: articles are created as internal notes, so nothing reaches a
customer unless type: "email" and internal: false are set deliberately. Destructive tools carry
destructiveHint and require an explicit confirm: true.
Zammad stores most email articles as text/html, where the markup dwarfs the message. Every tool
that returns an article renders the body as Markdown by default — headings, lists, link targets
and quote levels keep their meaning, at almost no cost over flat text — and drops two things:
type="cite"), open with a mail client's
attribution line, or trail the message are removed. A customer quoting an error message keeps it,
rendered as a Markdown quote;<span class="js-signatureMarker">, data-signature).Quotes are removed first and the signature is then located in what remains, because a customer's reply often quotes our own outgoing mail including its signature marker — cutting at the first marker found would truncate at that copy instead of at their own signature.
Signatures with no Zammad marker, such as a foreign sender's legal footer, are left alone. Recognising those means matching on wording, which is not reliable enough to risk cutting real content.
Across 377 articles from a live instance this cut 2.04M characters of stored body to 244K (−88%),
and the share of articles hitting the 4000-character cap fell from 43% to 2% — the truncation that
otherwise makes a model re-fetch the same article in full. body_omitted on each article records
what was dropped, and body_format: "html" returns the stored markup untouched.
Both come back as everything Zammad returned, minus two things: its internal bookkeeping (the SLA
counters, preferences, checklist plumbing) and the numeric twin of any field already spelled out —
expand=true returns group_id: 1 and group: "Users", so the number says the same thing twice,
and the name is what the write tools address anyway.
This is a denylist, deliberately. The curated field list it replaced kept twelve ticket fields and
dropped thirty-six, which was fine until an instance added an Object Manager attribute: the field
vanished on read while custom_fields still accepted it on write. Naming what to discard means
anything unknown survives.
On a real ticket that is 729 characters where the previous shape — a summary plus the untouched object beside it — was 2936. Article rows land within 2% of the old curated list while carrying every field it dropped.
output: "full" returns Zammad's object untouched, for the case where something is missing that
should be there. It replaces the raw_ticket and raw_article fields, which used to be attached to
every response whether or not anyone wanted them.
Conversion uses turndown. On Node it works as shipped; on
Cloudflare Workers it needs its bundled DOM rather than the host's, which the alias block in
examples/cloudflare/wrangler.jsonc arranges — see the comment there for why.
Zammad adds no signature server-side — the agent UI composes one into the body before it posts, so an
article written through the API would otherwise go out unsigned where the same article written by
hand would not. append_signature closes that gap, on by default, and sits on the article in
every tool that writes one:
| Tool | Where the flag lives | What the UI does |
|---|---|---|
zammad_create_ticket | article.append_signature | New Ticket screen, "Send Email" tab |
zammad_update_ticket | article.append_signature | reply composer, plus the group dropdown |
zammad_create_article | append_signature | reply composer on an open ticket |
The rules are the UI's:
type: "email", sender: "Agent"). A note or a phone article is never
signed, so the safe defaults stay untouched;#{user.firstname}, #{ticket.number}, #{config.fqdn} and friends are resolved the way
App.Utils.replaceTags resolves them, down to rendering anything unresolved as -. On create they
see the attributes the ticket is about to be given; on a reply, the ticket as it stands;<div data-signature="true" data-signature-id="…"> and appended after a
blank line. That marker is what Zammad's own reply handling looks for — and what the Markdown
rendering above strips again on read, so a signed article does not carry its signature into every
later quote.Every article these tools write is text/html. There is no content_type argument anywhere —
a format knob on a model-facing tool is an invitation to pick wrongly, and there is nothing to
pick: the agent UI composes nothing but HTML, and Zammad itself takes care of the text version.
Verified against Channel::EmailBuild and TicketArticleCommunicateEmailJob on stable: the
article's content_type is the send format — no flag exists to store one format and send
another — and a text/html article goes out as multipart/alternative with a plain-text part
Zammad generates via its own html2text. A text-only reader gets Zammad's rendering of the same
HTML, so nothing is lost by never writing text/plain.
A body may still be authored either way: plain prose is converted exactly the way the UI
converts pasted text (App.Utils.text2html: escaped, line breaks kept, each line a <div>, an
empty line a <div><br></div>), and a body already carrying a complete HTML tag is stored as it
is. The signature is appended as HTML, one to one as stored, never pre-rendered to text.
That conversion is a safety net, not a formatter, so the schemas ask for HTML outright, in one
shared sentence (HTML_BODY_NOTE) rather than four that drift apart. Escaping keeps a body written
in some other markup intact rather than rendering it — Markdown's **bold** reaches the reader as
asterisks — and the pull towards Markdown is real, since bodies are read as Markdown per the
section above. Describing the alternatives in the schema is what made the earlier wording read as a
choice between equals, so the note names the one supported format and stops there.
htmlToText survives on the reading side only — the previews, and the check that keeps a body an
older release signed as text/plain from going out signed twice when it is read back and resent.
The lookup never fails a write. A group with no signature, a signature an admin switched off, a
signature_id left dangling by a deleted signature, an empty body, a group that cannot be resolved,
a Zammad that refuses both signature sources — every one of them writes the article exactly as it was
given and reports why nothing was appended. Group signatures come from /api/v1/signatures, falling
back to the signshow assets on instances that restrict it; #{config.…} is read from signshow,
which is the only place a non-admin credential can see Zammad's settings.
Never two signatures. Both UI call sites check before they write, and so does this: a body that
already carries the same data-signature-id is returned byte for byte, and any other top-level
signature is removed before the new one is appended. A signature inside a blockquote is the other
side's and is left alone. This is what makes a retried call, or a body read back off an earlier
article, safe.
The one thing it cannot de-duplicate is prose. Kind regards, Jane typed into the body is
indistinguishable from the message, so if the caller signs off and the signature does, the reader
sees both. That can only be prevented where the body is written, so every signed result reports the
appended_text it added.
The rule is deliberately about the name only. A signature always ends with the sender's name, so
writing it in the body always duplicates it. The closing line above it is optional — some signatures
carry one, Zammad's own default does not — so instructing callers to omit the closing would produce
mail that jumps from the last sentence straight to a name. That judgement needs the actual text, which
is what zammad_get_group_signature is for:
{ "group": "1st Level" }
→ { "has_signature": true, "signature_id": 1,
"text": "Jane Agent\n\n--\nAcme Support · +49 …",
"html": "<div data-signature=\"true\" data-signature-id=\"1\">…</div>",
"template": "<br>#{user.firstname} #{user.lastname}<br>…" }
Pass ticket_id instead of group to resolve #{ticket.…} against a real ticket. It renders through
the same code that writes, so the preview and the article cannot drift — an integration test compares
them byte for byte. There is deliberately no derived "already has a closing" flag: a regex over prose
in any language would be a guess presented as a fact, and the caller reading text judges it better.
The rule itself is stated in one place, the append_signature description — not in the body field,
not in the tool descriptions, and not in the server instructions, which are read on every connection
whether an article is being written or not.
| Endpoint | Purpose |
|---|---|
POST /mcp | MCP Streamable HTTP (stateless) |
GET /health | Liveness probe |
GET / | Server info: transport, auth mode, metadata URL |
GET /.well-known/oauth-protected-resource/mcp | RFC 9728 protected-resource metadata |
GET /.well-known/oauth-authorization-server | RFC 8414 authorization-server metadata |
GET,POST /authorize · POST /token · POST /register · POST /revoke | OAuth proxy (proxy mode) |
GET /oauth/callback | The URL to register in Zammad |
npm install
npm run dev # Node host, watch mode
npm run dev:cf # Cloudflare Worker via wrangler dev
npm run verify # biome check + tsc --noEmit + tests, across all packages
npm run check:fix # apply Biome formatting and safe lint fixes
Lint and formatting are handled by Biome (biome.json).
The test suite covers the search builder's compilation rules against a fake lookup service, and runs the real Hono app end to end against a stub Zammad — including the OAuth registration/authorize/ callback round trip and the MCP handshake. It needs no network and no Zammad instance.
src/
core/ runtime-agnostic — no Node built-ins
index.ts public surface: bootstrap(), createApp(), loadConfig()
config.ts Zod-validated environment
app.ts Hono app: CORS, auth extraction, MCP endpoint
auth/ oauth.ts (stateless proxy) · signing.ts (WebCrypto HMAC)
util/ base64 · cache · logger · errors
zammad/ client · lookup · vocabulary · selector · search/
mcp/ server · context · result · tools/
node/ Node host: serve(), signals, .env
test/ config · cache · search-builder · server · env-file · tool-schema
Dockerfile multi-stage build of the Node host
compose.yaml the same, for `docker compose` and Coolify
examples/
cloudflare/ a deployable Worker consuming the published package
src/index.ts export default { fetch }
src/kv-cache.ts Workers KV behind the library's CacheStore
wrangler.jsonc vars, KV binding, compatibility flags
package.json depends on zammad-remote-mcp like any consumer
The Cloudflare host lives in examples/ rather than in the package. It is a deployment, not
something anyone installs from npm — and keeping it as an ordinary consumer of the published package
means the example proves the public API is usable, instead of reaching into internals a real user
could not reach.
src/core must stay free of Node built-ins. npm run build:worker is what enforces that — it links
this checkout into the example and runs a wrangler dry-run against the workerd target. CI runs it on
every push.
Three substitutions were enough, and each is a plain platform API rather than a shim:
| Was | Now | Why |
|---|---|---|
node:crypto createHmac / timingSafeEqual | crypto.subtle HMAC | Signing becomes async; subtle.verify already compares in constant time |
Buffer | atob / btoa / TextEncoder | Would otherwise tie the core to the nodejs_compat flag |
process.stderr.write | injectable sink, default console.error | process.stderr is not reliably present off Node |
Two further changes made one build valid on both runtimes:
WebStandardStreamableHTTPServerTransport (Request → Response),
not a Node- or Hono-specific one.McpServer is constructed with jsonSchemaValidator: new CfWorkerJsonSchemaValidator(). The SDK
otherwise instantiates Ajv eagerly, and Ajv compiles schemas with new Function, which edge
runtimes forbid. The validator is only consulted for elicitation responses, which a stateless
server never issues.Push a tag. .github/workflows/deploy.yml does the rest:
git tag v1.1.0 && git push origin v1.1.0
The workflow bumps the version to match the tag, runs lint/typecheck/build/test, builds the Worker,
commits the version bump back to main, publishes to npm over OIDC and opens a GitHub release. A tag
with a prerelease suffix (v1.1.0-rc.1) publishes under the next dist-tag instead of latest.
npm run verify
npm pack --dry-run
The tarball should contain dist, .env.example, README.md and LICENSE — no src, no tests,
no .tsbuildinfo.
MIT
FAQs
Stateless remote MCP server for Zammad — full ticket operations, an intelligent search layer and OAuth 2.1, running on Node.js or Cloudflare Workers from one codebase.
The npm package zammad-remote-mcp receives a total of 41 weekly downloads. As such, zammad-remote-mcp popularity was classified as not popular.
We found that zammad-remote-mcp demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
GPT-6 Astra hits 100% on ExploitBench and finds zero-days autonomously, while independent tests reveal scope violations and monitoring gaps.

Product
Socket can now send alerts and supply chain attack notifications to Microsoft Teams, with filters that route the right updates to each channel.

Security News
pnpm 12 rewrites the package manager in Rust, cutting install times by up to 90% while preserving pnpm 11 workflows and lockfiles.