Project
Security policy
On this page
Reporting a vulnerability#
Please do not open a public issue. Report vulnerabilities privately through GitHub private vulnerability reporting. You will get an acknowledgement within 72 hours and a fix or mitigation plan within 14 days for confirmed issues. Credit is given in the advisory unless you prefer otherwise.
Only the latest released version receives security fixes.
Threat model#
This server sits between an AI model and a sevDesk account. The design assumes:
- The sevDesk API token is all-powerful. sevDesk tokens have no scopes and never expire; they
can do everything their user can, on the whole account. The server therefore enforces its own
least privilege: tools are grouped into tiers (
read,write,commit,external), onlyreadis enabled by default, and tools above the allowed tiers are never registered. The tier is enforced again in the dispatcher and in the HTTP client, which also knows the minimum tier of each endpoint (for examplesendBy,bookAmount,enshrine,cancelInvoice, finalizing a voucher and changing a bank transaction needcommit;sendViaEmailneedsexternal) and refuses a request from a tool whose tier is too low. - The model can be manipulated. Text stored in sevDesk (contact names, invoice and voucher
descriptions, bank transfer purposes, e-invoice contents) can be written by third parties and
may contain prompt injection. Such text is treated as untrusted data: it is fenced in output,
stripped of invisible Unicode, and never placed in error messages. Legally binding or external
actions (
commit,external) require human confirmation outside the model's control (MCP elicitation or an approval page, see below); a model-suppliedconfirmflag is never accepted as approval. - There is no sandbox. Every write is real. Writes are never retried automatically (to avoid
duplicates), are read back to verify, can be dry-run (
dryRun: trueorSEVDESK_DRY_RUN=1: the tool reads, checks and returns its preview, sends nothing and asks nobody), and can be recorded in a local audit log. - Secrets must not leak. The token is read from the environment, a file or the OS keychain; it is never logged, echoed, or included in errors. Logs go to stderr only. In remote mode the same holds for the bearer and server secrets, sealed tokens, authorization codes and cookies.
- Local files are a boundary too. The model names files, so the server must not become a way
to read or write arbitrary files. Tools read only inside
SEVDESK_FILE_DIRS(absolute folders; real paths are checked, symbolic links may not lead outside, the file is opened without following a link and checked by inode, its type is recognized by content, sizes are capped) and write only insideSEVDESK_OUTPUT_DIR(new files only, mode 0600, never overwriting). Without these settings, no tool reads or writes local files. Received e-invoices are hostile input: the XML parser refuses DOCTYPEs and entities and caps size and nesting depth, the PDF attachment reader caps decompressed sizes, and every parsed text field is treated as untrusted. The DATEV export ZIP is fetched from a presigned link that sevDesk returns; the server accepts exactly one https host for it, follows no redirects, sends no credentials, caps the size and never shows the link to the model. - Remote mode adds a network boundary. The HTTP transport refuses to start without authentication, binds 127.0.0.1 by default (any other address needs a configured public URL), and validates Host and Origin. In OAuth mode it is single-tenant by default (only listed sevDesk accounts can connect, only allowlisted clients can ask), stores no sevDesk tokens (each user's token travels sealed inside the tokens their client holds), and scopes approvals, confirmations and caches to the account and grant of each request. See Remote mode.
Out of scope: vulnerabilities in sevDesk itself (report those to sevdesk GmbH), and attacks that require control of the machine running the server.
Human confirmation#
Every commit and external call shows the human a preview built by the tool from what it just
read: the action in one sentence, then the warnings and "This cannot be undone." for irreversible
actions, then the key facts (record, customer, amounts, dates, recipients, texts). Values are stripped of
invisible, bidi and control characters, so stored text cannot hide anything from the human. Fact
values are shown in full with their line breaks (an email text, for example), each line indented
under its label so that no line of a value can pose as a label or a warning; a value longer than
12,000 characters ends with "[… N more characters not shown; open the dry run to see everything]"
instead of being cut silently, and the dry run returns it whole. The approval binds a hash of the
complete preview, not of what fit on the screen. Only the human's answer counts: no tool argument
can approve anything.
The other way round, text the human cannot see must not reach sevDesk either. Before anything is read or sent, every write tool (dry runs included) refuses arguments that contain characters a human would not see as they are: control and format characters (zero-width, bidi), other default-ignorable code points (variation selectors, U+034F, Hangul fillers), carriage returns, the line and paragraph separators, and tabs. The error names the argument and the code points; line breaks are allowed.
SEVDESK_CONFIRM=elicit (default). The call answers with an MCP elicitation through the
2026-07-28 multi-round-trip flow (input_required): a form with a single "Approve this action"
switch, off by default. The pending state travels through the client as requestState, signed with
HMAC-SHA256 (the SDK's request-state codec, a random 256-bit key per server process, valid for 10
minutes, bound to the method, the client and, in HTTP mode, the grant and the account). It binds the
tool name, a SHA-256 of the arguments (without dryRun), a SHA-256 of the preview and a single-use
nonce. On the retry the server refuses a state for other arguments or another tool, refuses a
replayed approval (the nonce is consumed once), prepares the action again and, if the preview
changed in the meantime (the record was edited), asks again instead of executing. Decline, cancel or
an unchecked switch return declined: nothing changes. For 2025-era clients the SDK's legacy shim
sends a real elicitation/create request on the same connection, so the same checks apply. Clients
that do not declare form elicitation, and 2025-era clients over stateless HTTP (which cannot receive
server-to-client requests), get an error telling the user to switch to browser; nothing is sent.
SEVDESK_CONFIRM=browser over stdio. For clients without elicitation, the server creates an
approval page, served by itself on 127.0.0.1 and an ephemeral port only, and opens it in the user's
browser with the operating system's opener (open, xdg-open or cmd /c start, spawned with an
argument array, no shell). SEVDESK_CONFIRM_OPEN=0 turns the opening off. The page's link is a
secret: it goes to the server log (stderr, one line per approval) and to the opener, never to the
model, the tool result or the audit log. The call returns approval_pending with a short,
non-secret reference code (also shown on the page, so the user can match them), the expiry and
whether the page was opened. Each approval binds tool, argument hash and preview hash, has a random
256-bit id, expires after 10 minutes and is used once; when the model calls the tool again with the
same arguments after the human approved, the server prepares again and runs the action only if the
preview is unchanged. The page is static HTML with every value escaped, under a strict Content
Security Policy (no scripts, no framing, forms only to itself). Approve and Reject are POST forms
that must carry a per-approval CSRF token from the page, a per-approval SameSite=Strict cookie
set when the page was loaded, Host: 127.0.0.1:<port> (every request is checked for the Host,
against DNS rebinding) and come from the page itself: Origin: http://127.0.0.1:<port>, or, from
clients that send no Origin, the browser-set Sec-Fetch-Site: same-origin. Origin: null is
refused; the page uses the same-origin referrer policy, under which browsers send the real
origin (under no-referrer they would send null). At most five approvals wait for a decision at
a time; beyond that, calls are refused until the user decides or the requests expire, so a looping
assistant cannot flood the browser with tabs. In remote mode browser uses the server's approval
inbox instead (see Remote mode).
Limit of the browser mode. The model never sees the link, but the link is still a
capability. An agent that can read the server's stderr log (for example a client that shows MCP
logs to the model, or shell access to the log file) or that can drive the user's browser (an open
page, browser automation) can still approve on its own. The browser mode therefore protects against
a model that only talks (for example one steered by prompt injection in sevDesk data), not against
an agent with such access. elicit stays the recommended mode, especially in agentic clients
that can run commands.
SEVDESK_CONFIRM=off is accepted only together with SEVDESK_I_UNDERSTAND_NO_CONFIRM=1.
Audit log#
With SEVDESK_AUDIT_LOG=<path> every write call appends JSON Lines to that file: dry_run,
confirmation_requested (with the preview and, in browser mode, the reference code),
confirmation_refused (a replayed, expired or mismatched confirmation), approval_decided (Approve
or Reject pressed on the approval page, with the reference code), declined, executing (with how
it was approved: elicit, browser, confirmation_off or not_required for the write tier),
executed (with the records changed) and failed (with the error kind and any records an earlier
step already changed). Each line has a timestamp, a call id shared by the request, the decision and
the execution, the tool, its tier, the argument hash, the hash of the preview that was shown and
approved, and the arguments (token redacted, hidden characters written as visible \u{…} escapes,
strings up to 20,000 characters, longer ones marked). In remote mode each line also has account
(the sevDesk account id; bearer in bearer mode if the startup lookup of the account failed) and
grant (the grant id, or bearer), and one log covers every account the server serves. The file is
created with mode 0600 (and tightened to 0600 if it already exists), opened append-only and never
through a symlink. If the executing line cannot be written, the action is not run and nothing is
sent (fail closed). The log is local and not tamper-proof: it records what this server did, for the
user, and is no defense against someone who controls the machine. Every line starts with ts and
the format version v (currently 1).
Generic read surface#
sevdesk_api_get (toolset advanced, off by default) lets the model choose a GET path, which a
curated tool never does. It has four independent guards:
- The configured sevDesk token anywhere in the path or the query is refused, and the refusal does not repeat the path.
genericGetRefusalrefuses, before anything is sent, endpoints that change state, return files or start jobs (getPdf,getXml,render,sendBy,bookAmount,enshrine,cancelInvoice, the reset endpoints,getNext…Number, segments that start with a mutating verb, and everything underExport,ExportJob,Progress,Report,DocServerandSevSequence), and dangerous query keys: credentials (sevDesk accepts?token=), paging keys the tool sets itself,download,preventSendBy,enshrine…,useNextNumberand method overrides. Query keys are normalized the way PHP reads them, soApi.Keyortoken[0]are caught too.- The read-only client refuses every GET with a side effect that
resolveGetSafetyrecognizes. - The HTTP client refuses it again, because a read declares no tier.
Answers are read with a 1 MiB cap, must be JSON in sevDesk's {objects: …} envelope, and are
cleaned as untrusted text: invisible characters removed, strings capped, binary content (base64
files, thumbnails) and sevClient echoes removed and listed, and at most about 100 KB returned.
The residual risk is an undocumented GET with a side effect that none of these rules matches; the
toolset is off by default for that reason.
Local token storage and profiles#
Over stdio the token comes from the environment, a file (SEVDESK_API_TOKEN_FILE) or the OS
keychain (SEVDESK_API_TOKEN_KEYCHAIN).
- Keychain. Items live under the fixed service
mcp-server-sevdesk; configuration names only the label, so a hostile configuration cannot make the server read another keychain item (a password, another token) and send it to a customSEVDESK_BASE_URL. The server runs the operating system's helper (/usr/bin/securityon macOS,secret-toolon Linux) with an argument array, never through a shell. When storing, the token travels on the helper's stdin, never in its arguments, which other local users could see in the process list. The helper's output is capped, never logged or echoed, and errors are fixed texts. The keychain protects the token at rest, in configuration files, backups and screenshots; it does not protect against code that already runs as the same user. On macOS the item trusts/usr/bin/security, which created it, so any process of the user can read it that way without a dialog. Windows is not supported yet and is refused with a pointer toSEVDESK_API_TOKEN_FILE. - The command line.
token setreads the token from stdin (hidden on a terminal), never from an argument, where it would stay in the shell history. It checks the token with sevDesk and stores nothing sevDesk rejects. Labels, profile names and unknown arguments that look like a token are never echoed. - Profiles. A profile can widen tiers and folders, so on macOS and Linux the profiles file and
its folder (also the real folder behind a symbolic link) must belong to the user or root and
must not be writable by group or others; otherwise the file is refused. Windows does not check
this. Profiles never hold a token, only where it is kept, and a profile's
token.envmay name onlySEVDESK_TOKEN_*variables, so a profile cannot send an arbitrary secret from the environment to a custom base URL. A variable set both in the environment and by the profile is an error; onlySEVDESK_READ_ONLY=1andSEVDESK_DRY_RUN=1in the environment may tighten a profile silently. Messages about the file never quote its content. - The HTTP mode refuses
SEVDESK_PROFILE,SEVDESK_PROFILES_PATHandSEVDESK_API_TOKEN_KEYCHAIN: a server has no user keychain, and its configuration belongs in its environment.
Claude Desktop extension#
The .mcpb bundle runs the same server over stdio on Claude Desktop's built-in Node.js. Claude
Desktop stores the token, a setting marked sensitive, in the operating system's secure storage
(Keychain on macOS, Credential Manager on Windows) and passes it to the server as
SEVDESK_API_TOKEN when it starts it; the token never appears in the command line. The bundle
contains the server and all its dependencies in one file that imports only Node.js built-ins, with
the license notices of every bundled package. Confirmation cannot be switched off in the
extension: it offers no way to set SEVDESK_I_UNDERSTAND_NO_CONFIRM. The bundle is not signed
yet; verify a download with its provenance attestation (see Supply chain).
Remote mode#
The HTTP transport (--http, operator's guide in docs/remote.md) adds a network
boundary and, in OAuth mode, several accounts and connections in one process. The decisions are
ADR 0006 and
ADR 0007.
Network. The server refuses to start without authentication. It binds 127.0.0.1 by default and
needs a public URL for any other bind address, and a public URL must use https unless its host is a
loopback name. The Host of every request must be one of the configured names (against DNS
rebinding; only /healthz is exempt, for platform health checks). An Origin header on /mcp must
name an allowed host (the check compares host names; /mcp sends no CORS headers, so web pages on
other origins cannot call it), and form POSTs must come from the server's own pages (Origin: null
is refused). Bodies are capped per route and must arrive within a short time, requests have
deadlines, rate limits apply per client address (IPv6 per /64 network), and the number of requests
in flight is capped in a way that one client cannot use up for everyone. Responses carry nosniff,
no-store, no-transform (so proxies do not rewrite pages), a referrer policy and, on https, HSTS. URLs the server prints or redirects to are built
from the configured public URL, never from request headers. SEVDESK_HTTP_TRUST_PROXY must match
the real number of proxies in front: a higher value lets clients choose the IP address the rate
limits see. Plain http is only accepted for a loopback public URL; keep such a setup to quick local
tests (see the inbox limits below).
Bearer mode. One shared secret (two during a rotation), at least 32 characters with at least 16
different ones, and never equal to the sevDesk token. The server compares an HMAC of the presented
value with an HMAC of each configured value using timingSafeEqual, without stopping at the first
match. Whoever holds the secret can use the server's tiers on its account. Startup warns when a
bearer or server secret looks like a passphrase rather than random bytes.
OAuth consent and allowlists. The consent page shows the host of the client's client_id, the
client's self-declared name (sanitized and labelled as such) and the host it will redirect to, with
a warning when every redirect goes to a loopback address (an app on the user's machine). Only read
is preselected; other tiers must be ticked. Clients get that far only when their metadata document
is on a host in SEVDESK_OAUTH_CLIENTS (default claude.ai), checked before anything is fetched:
an attacker cannot make your consent page present their own client (consent phishing). Only sevDesk
accounts in SEVDESK_OAUTH_ACCOUNTS can complete the consent, so a server whose URL becomes known
does not turn into a service for strangers' accounts; a refused account is logged as
account_refused. Both allowlists also apply to every request and refresh: after an account or
client host is removed and the server restarted, its existing grants are revoked at their next use
and the account's inbox sessions end. A checked consent can complete only once, and each account
holds a limited number of live grants (a new consent beyond it replaces the account's oldest), so
one account cannot fill the grant store for everyone else. The page uses a SameSite=Strict CSRF
cookie with a matching HMAC field, accepts POSTs only from itself, cannot be framed, and its Content
Security Policy sends forms only to itself and the validated redirect origin. Errors in the client,
its metadata or the redirect URI show an error page and never redirect. The pasted sevDesk token is
checked with sevDesk and never shown again.
Fetching client metadata (SSRF). Fetching a client_id URL is a request the server makes for
someone else. The URL must be https on a DNS name, without query, fragment, credentials or dot
segments, on an allowlisted host. The name is resolved once, every address must be public
(private, loopback, link-local, CGNAT, unique-local, documentation and multicast ranges are
refused, also inside NAT64, 6to4 and IPv4-mapped addresses), and the connection goes to the
address that was checked. No redirects, 3 seconds to connect and 5 in total, application/json
only, at most 5 KiB. The document must name the same client_id, list 1–10 redirect URIs and have
no client secret. Refusals are logged as cimd_blocked.
OAuth tokens and revocation. PKCE with S256 only; the verifier's hash is compared in constant
time. Redirect URIs match exactly (loopback redirects: the host exactly, any port). Authorization
codes live 60 seconds and are used once: a second exchange revokes the grant (code_replay).
Access tokens live 1 hour and are valid only for <public URL>/mcp (RFC 8707, exact match), and
every request looks the grant up, so a revocation takes effect on the next request. Refresh tokens
are replaced on every use; the previous one is accepted for 30 seconds after a rotation, and any
later use revokes the grant (refresh_replay). Every refresh checks the sevDesk token with sevDesk
again, and a 401 revokes the grant. A grant ends after 90 days at the latest. All tokens are
AES-256-GCM-sealed under a key per purpose, with type, issuer and key id authenticated, and carry
a readable sdmcp_ prefix so redaction and secret scanners find them. Access ends with
grants revoke (one grant, an account, or all), the client's /revoke call (RFC 7009), a new
server secret without _PREVIOUS (everything), or a regenerated sevDesk token. A regenerated
token stops tool calls at once, because sevDesk refuses it, and revokes each grant that holds it
at its next refresh; it does not end inbox sessions, since a sign-in with a pasted token is not
tied to that token (it lasts up to 12 hours) and a consent session lasts until its grant is
revoked. grants revoke --account <id> ends the grants and every inbox session of the account at
once. A server that loses its lock on the state directory to another stops trusting its grant
store (OAuth requests fail with a server error) and exits, so it never answers from grants it can
no longer keep current.
What the server secret can decrypt. Every key comes from SEVDESK_HTTP_SECRET. Someone who
has it and captures an authorization code, an access or refresh token, or a consent page's form
state (from a client, a proxy log, a TLS-terminating middlebox) can read the sevDesk token inside.
The secret alone yields no sevDesk token, because the server stores none. Keep it in a secret
store (SEVDESK_HTTP_SECRET_FILE), never in the image, the repository or a deploy template, and
protect the state directory as well: with the secret and a copy of it, someone could forge an inbox
session for a listed session id. If the secret may have leaked, replace it without _PREVIOUS
(every client reconnects); if tokens may have leaked too, regenerate the sevDesk tokens and run
grants revoke --account <id> for the affected accounts.
The approval inbox. In remote mode SEVDESK_CONFIRM=browser serves an inbox at
<public URL>/approvals. Its URL is not a secret: the model sees it in the tool result, and without
a session the inbox shows only a sign-in form, no approval data. A browser gets a session at the
OAuth consent, or by pasting the sevDesk token (OAuth: checked with sevDesk and against the account
list; bearer: compared in constant time with the server's SEVDESK_API_TOKEN, never the bearer
secret, which the client holds). Sign-ins are limited to 5 a minute and 20 an hour per client
address (IPv6 per /64 network).
The session cookie is sealed and holds no sevDesk token, is HttpOnly, Secure, SameSite=Lax
and __Host- prefixed, lasts 12 hours, gets a new id at every sign-in and ends with its grant or
with grants revoke --account.
Lax is needed because the link is followed from claude.ai, a cross-site navigation; decisions are
POSTs that must also carry a CSRF token bound to the session and come from the inbox itself
(Origin, or Sec-Fetch-Site: same-origin; never Origin: null). Each decision checks that the
approval belongs to the session's account. An approval belongs to the grant that asked: a retry
from another connection, even of the same account, cannot use it. Approvals still bind tool,
arguments and preview, expire after 10 minutes, are used once, and at most five per account wait at
a time; the store's overall limit cannot be used by one account to push out another account's
approvals.
Limits of the inbox. An agent that can drive a signed-in browser (browser automation, a
computer-use tool) can still approve on its own: like the local page, the inbox protects against a
model that only talks. A lookalike page could phish the sevDesk token; paste it only on your own
server's /approvals (most users never need to, because the consent signs the browser in). Everyone
who can sign in to an account shares its inbox and can decide its approvals; they hold that
account's token anyway. A session stays valid for 12 hours on that browser, so sign out on a shared
computer; regenerating the sevDesk token does not end it (use grants revoke --account). On a
plain-http loopback URL (development) the cookie has no __Host- prefix and no Secure flag, and
browsers do not separate cookies by port: any other web server the browser reaches on that host
name receives the session cookie and could read and decide approvals. Use https, or a dedicated
browser profile, for anything beyond a quick local test. A restart voids pending approvals.
Logs. One JSON line per request on stderr: the route template, status, duration, client IP and
the first 8 characters of the grant id. Headers, bodies, cookies and query strings are never logged,
and every line is scrubbed of the sevDesk token, the bearer secrets, the server secret, sealed
tokens and any run of 32 or more hex digits (the shape of a sevDesk token). Errors, HTML pages and
redirects never contain a secret, and the in-memory grant object leaves the sevDesk token out when
it is serialized or inspected. Security events are logged by name (refresh_replay, code_replay,
account_refused, cimd_blocked).
What stays the operator's job. TLS in front of the server (a reverse proxy, a platform's edge or a tunnel; whoever terminates TLS can see tokens in transit, so choose a provider you trust and keep quick tunnels for tests), the server secret, a persistent volume for the state directory, exactly one instance, narrow allowlists, and updating the image.
Remote mode: files#
With SEVDESK_HTTP_FILES on (the default), files reach the tools through upload pages and leave
as download links on the server (docs/remote.md,
ADR 0008). The model never sees or sends file bytes.
Links are not capabilities. sevdesk_upload_request returns <public URL>/uploads/<id> and
the delivering tools return <public URL>/files/<id>: the model sees both, so neither is a secret.
Every page needs the browser session of the approval inbox for the record's account (set at OAuth
consent, or by signing in with the account's sevDesk token; in bearer mode never with the bearer
secret). Without a session a page shows only the sign-in form, whose next accepts only the
server's own record paths (no open redirect). A record of another account answers exactly like an
unknown id (404). Tools read only uploads their own grant asked for; another grant, even of the
same account, gets the same refusal as an unknown id. Ids are 256-bit random; error messages,
summaries and logs never contain them.
Uploads. The page shows only trusted values (purpose, accepted kinds, size limit, account,
reference, expiry), so a prompt-injected model cannot put instructions on it. An upload is a
same-origin POST (Origin: null refused) with a CSRF token bound to the session, exactly the
fields csrf and file, and passes its checks before the body is read: rate limits (10 a minute
and 60 an hour per client), the session, an open ticket of that account, a declared
Content-Length (411 without) within the limit (413), and at most two bodies at once (503). The
file type is checked by content (receipts PDF, PNG, JPEG; e-invoices XML, PDF); no ZIP is
accepted, images are never decoded, and the e-invoice parser keeps its own guards against hostile
XML and PDFs. Names are cleaned to [A-Za-z0-9._-] (umlauts spelled out, path parts, control and
invisible characters, quotes and markup removed, at most 100 characters, extension from the
content), always escaped, and never used as paths. Each page takes one file; uploads are never
served back.
Downloads. The bytes are fetched from sevDesk during the tool call (PDFs with
preventSendBy=true); opening a link makes no sevDesk request and has no side effect. Responses
are attachments only (Content-Disposition: attachment with an ASCII and an RFC 5987 name), with
Content-Security-Policy: default-src 'none'; sandbox, nosniff, X-Frame-Options: DENY,
Cross-Origin-Resource-Policy: same-origin, Referrer-Policy: no-referrer and
Cache-Control: no-store: a file is never rendered on the server's origin, where XML could run
script. HEAD is refused. A link serves at most 5 downloads.
Storage. Records live in memory only; the bytes in memory or in <stateDir>/files/ (folder
0700, files 0600 created exclusively without following links, random names), encrypted with
AES-256-GCM under a key drawn at random when the process starts and never written anywhere, with
the blob id, account and kind authenticated (a blob swapped between records fails to open). Files
are deleted when they expire and at shutdown, and the folder is emptied at every startup, after the
state directory's lock is taken (a second server never touches a live one's files). A copy of the
volume or of the server secret opens none of them. Upload pages live 15 minutes, uploads and links
SEVDESK_HTTP_FILES_TTL (30 minutes, at most 24 hours).
Exhaustion. One upload is at most 20 MiB; one account holds at most 5 open upload pages, 10
uploads and links and half of SEVDESK_HTTP_FILES_MAX_MB; the server at most 1,000 records and the
full budget. Nothing is evicted, so one account cannot push out another's files; new requests are
refused until older ones expire.
Logs and audit. Log lines and file_received, file_link_created and file_downloaded audit
lines carry the account, grant, reference code, kind, size and SHA-256, never ids, file names or
content; request lines show the route templates /uploads/* and /files/*, and an upload: tool
argument is written to the audit log as upload:[REDACTED].
Limits. An agent that can drive a signed-in browser could upload or download on its own, as it
could approve in the inbox. On a plain-http loopback URL other local web servers receive the
session cookie (startup warns). Whoever terminates TLS sees the files in transit. With
SEVDESK_OAUTH_ACCOUNTS=any the server briefly holds other people's receipts and exports (startup
warns). A downloaded file is whatever sevDesk returned.
Supply chain#
Releases are published from CI with npm provenance (trusted publishing, no long-lived tokens), GitHub Actions are pinned by commit SHA, and the repository is scanned by CodeQL, dependency review and OpenSSF Scorecard.
The container image ghcr.io/jaylann/mcp-server-sevdesk is built by the release workflow from base
images pinned by digest. It runs as a non-root user (uid 65532) on a distroless Node.js base, and
each release carries a build-provenance attestation and BuildKit's SBOM and provenance. Verify an
image before you run it, then pin the digest you verified:
gh attestation verify oci://ghcr.io/jaylann/mcp-server-sevdesk:<version> --owner jaylanndocker buildx imagetools inspect ghcr.io/jaylann/mcp-server-sevdesk:<version> --format '{{ json .SBOM }}'The Claude Desktop extension (mcp-server-sevdesk-<version>.mcpb) is built by the release workflow
too and attached to the GitHub release with a build-provenance attestation:
gh attestation verify mcp-server-sevdesk-<version>.mcpb --owner jaylann