Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ MCP_AUTH_TOKEN=
#
# This server's public URL — used as the token audience / Resource Indicator.
# Required in OAuth mode (SERVER_URL is accepted as an alias).
#
# It ALSO applies outside OAuth mode: create-upload-ticket builds its browser URL and
# its curl command from this value in every auth mode. If you run with MCP_AUTH_TOKEN
# (or unauthenticated) behind a real domain, set it here — otherwise those links fall
# back to http://127.0.0.1:$PORT, which resolves nowhere but on the server itself.
# OAUTH_RESOURCE=https://your-server.example.com
# SERVER_URL=https://your-server.example.com
#
Expand Down Expand Up @@ -55,6 +60,14 @@ MCP_AUTH_TOKEN=
# Finalize / legally-binding write tools (issue invoices). IRREVERSIBLE. Default: false
# LEXWARE_ENABLE_FINALIZE=false

# Hosts that upload-file-from-url may download from (comma-separated). Matched on the host
# itself or any subdomain, on a dot boundary — "evilsharepoint.com" does NOT match
# "sharepoint.com". Setting this REPLACES the defaults rather than adding to them, so you can
# opt out of the Microsoft file-sharing domains entirely. Setting it to an empty value blocks
# every host, which disables the tool — an empty list never means "allow everything".
# Default: sharepoint.com,onedrive.live.com,1drv.ms,graph.microsoft.com
# LEXWARE_UPLOAD_ALLOWED_HOSTS=files.example.com,storage.example.org

# ---- Server ----
# Listen port. Cloud Run injects this automatically. Default: 8080
# PORT=8080
Expand Down
66 changes: 66 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,72 @@ All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- **Upload files without pushing their bytes through the model context.** `upload-file` /
`upload-voucher-file` take the file as base64 inline in the JSON-RPC body, so every byte is billed as
tokens, sits in the conversation transcript, and a ~8 MB receipt runs into the 12 MB body limit — the
file has to travel through the model even though the model has no use for its contents. Three new
drafts-tier tools take a different route:
- **`create-upload-ticket`** issues a short-lived (15 min), single-use ticket and returns both a browser
URL for drag-and-drop and a ready-to-run `curl` command. The bytes go client → server → Lexware; the
model only ever sees the resulting file id.
- **`get-upload-result`** reads back the file id once the transfer has happened.
- **`upload-file-from-url`** fetches the file server-side from a URL.

**`LEXWARE_UPLOAD_ALLOWED_HOSTS`** configures which hosts `upload-file-from-url` may download from
(comma-separated; the host itself or any subdomain, matched on a dot boundary). It **replaces** the
built-in defaults rather than extending them, so a self-hosted server can stop trusting them; setting
it empty blocks every host and disables the tool. An empty list never means "allow everything".
Default unchanged: `sharepoint.com`, `onedrive.live.com`, `1drv.ms`, `graph.microsoft.com`.

The ticket store is **in-process**, so this is a single-instance feature: a restart drops open tickets
(they answer `410`, they do not hang), and behind a load balancer without sticky sessions an upload can
reach a different instance than the one that issued the ticket. The 15-minute lifetime bounds the
window; an external store would mean a new infrastructure dependency for what is otherwise a
self-contained server.

Supporting pieces: an in-memory ticket store, the `GET`/`POST /upload/:ticket` endpoints with their
self-contained upload page, and a URL fetcher hardened against SSRF (host allowlist, redirect
re-validation, and rejection of loopback/link-local/private address ranges after DNS resolution).
`filename` and `mimeType` travel as `X-Filename-B64` (base64url of the UTF-8 bytes) rather than raw
header bytes, so names containing an en dash, typographic quotes or an emoji survive — a raw header
value is Latin-1 on the wire and `fetch()` refuses anything above U+00FF outright.
- **Body parsing now also defers `/upload` paths** from the pre-applied global JSON parser, alongside
`/mcp`. The upload routes read the raw body themselves; letting the JSON parser run first turned a
JSON-content-typed upload into an empty file.

### Changed
- The existing base64 upload tools are unchanged and remain available — the ticket route is additive.
- **`SERVER_URL` (or `OAUTH_RESOURCE`) now applies in every auth mode, not only OAuth.** It is the
server's public URL, and `create-upload-ticket` builds its browser link and its `curl` command from
it. Previously that base URL was derived from the auth mode — the OAuth resource, otherwise
`http://127.0.0.1:$PORT` — so a static-token deployment behind a real domain (a documented,
supported mode) set `SERVER_URL`, had it ignored, and handed out upload links that resolve nowhere
but inside the container. The value goes through the same HTTPS validation as every other
configured URL, so a typo fails at startup instead of surfacing in a command an operator runs.
Unset, the loopback fallback still applies, now on the configured `PORT`. Nothing changes for
OAuth deployments.

### Fixed
- **The upload page no longer builds its success message as an HTML string.** The Lexware file id
came back from the API and was interpolated into `innerHTML`; it is now a text node inside a
`<code>` element, so it still renders as code but can never be parsed as markup. The error path
already did this.
- **Filenames from `Content-Disposition` are stripped of control characters and length-capped.**
Sanitizing only removed path separators and trimmed the ends, so
`filename*=UTF-8''evil%0D%0Ainjected.pdf` arrived as `evil\r\ninjected.pdf` — CRLF intact, because
it sits in the middle — and went on into the multipart field and into anything that logs the name.
C0 controls and DEL are now removed wherever they appear, and the result is capped at 255
characters (the per-component limit of every common filesystem) without splitting a surrogate pair.
A name left empty by this yields the existing fallback chain instead. Legitimate non-ASCII names
(umlauts, en dash, emoji) are untouched.
- **Expired upload tickets are dropped as soon as they are seen**, in `claim()` and `peek()`, not only
by the sweep that runs on the next `create()`. An instance that issued tickets and then went quiet
used to hold the expired entries for as long as it stayed up. Externally nothing changes: still
`410`, still `undefined`.

## [0.1.7]

### Added
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ Related projects — local (stdio) Lexware MCP servers:

## Capabilities

60 tools across three tiers you enable via environment variables:
63 tools across three tiers you enable via environment variables:

| Tier | Default | What it covers |
|------|---------|----------------|
| **Read** | always on | Profile; contacts & articles (list/get); the voucherlist (plus `summarize-vouchers` for server-side totals); full documents (invoices, quotations, credit notes, order confirmations, delivery notes, dunnings, down-payment invoices, vouchers); **render any document type to PDF** and **download files/receipts** (returned inline as embedded resources); batch & type-dispatched reads (get-vouchers, get-document, get-voucher-file, get-document-file); payments; reference data (countries, payment conditions, posting categories, print layouts); recurring templates (get & list); event subscriptions; document deeplinks |
| **Drafts/writes** (`LEXWARE_ENABLE_DRAFTS`) | on | Create **draft** invoices/quotations/credit-notes/order-confirmations/delivery-notes/dunnings (the Lexware API has no update endpoint for these — set every field, including payment terms, at creation); create & update contacts, articles, and **bookkeeping vouchers**; **upload files** and **attach receipts** to vouchers; create documents as **follow-ups** (`precedingSalesVoucherId`) |
| **Drafts/writes** (`LEXWARE_ENABLE_DRAFTS`) | on | Create **draft** invoices/quotations/credit-notes/order-confirmations/delivery-notes/dunnings (the Lexware API has no update endpoint for these — set every field, including payment terms, at creation); create & update contacts, articles, and **bookkeeping vouchers**; **upload files** and **attach receipts** to vouchers — inline as base64, or **without base64** via a short-lived upload ticket (`create-upload-ticket` → browser drag-and-drop or a `curl` one-liner → `get-upload-result`) or straight from a URL (`upload-file-from-url`, host-allowlisted); create documents as **follow-ups** (`precedingSalesVoucherId`) |
| **Finalize** (`LEXWARE_ENABLE_FINALIZE`) | off | Issue **legally binding** finalized documents in one step via the dedicated `create-finalized-*` tools (confirmation-gated); irreversible article deletes; **manage webhook event subscriptions** (create + delete — a webhook streams financial events to an external URL, so it's opt-in). Enabling this tier also enables Drafts. |

Set `LEXWARE_READ_ONLY=true` to force read-only (overrides the flags above).
Expand Down Expand Up @@ -94,7 +94,7 @@ LEXWARE_API_KEY=... MCP_AUTH_TOKEN=... npm start
|---|---|---|
| `LEXWARE_API_KEY` | — (**required**) | Your Lexware API key ([create one](https://app.lexware.de/addons/public-api)) |
| `OAUTH_ISSUER` | — | OAuth authorization-server issuer URL. Setting it enables OAuth mode¹ |
| `OAUTH_RESOURCE` / `SERVER_URL` | | This server's public URL (token audience / Resource Indicator). Required in OAuth mode |
| `OAUTH_RESOURCE` / `SERVER_URL` | `http://127.0.0.1:$PORT` | This server's public URL. **Required in OAuth mode** (token audience / Resource Indicator), and used in *every* mode to build the upload links `create-upload-ticket` hands out (browser URL and `curl` command). Set it whenever the server is reachable under a real domain — without it those links point at the loopback fallback, which only works on the server itself |
| `OAUTH_ALLOWED_EMAIL_DOMAINS` | — | Comma-separated allow-list of email domains (e.g. `example.com`) |
| `OAUTH_VERIFY_AUDIENCE` | `true` | Verify the token `aud` matches `OAUTH_RESOURCE`. **Keep `true`.** Setting `false` accepts *any* valid token from the issuer — including one minted for a different app on the same issuer (a confused-deputy risk). Only disable for a dedicated, single-audience issuer that has no Resource Indicator |
| `OAUTH_JWKS_URL` / `OAUTH_USERINFO_URL` | derived from issuer | Override the JWKS / OIDC userinfo endpoints (defaults use the WorkOS-AuthKit layout) |
Expand All @@ -104,6 +104,7 @@ LEXWARE_API_KEY=... MCP_AUTH_TOKEN=... npm start
| `LEXWARE_READ_ONLY` | `false` | Register only read tools (hard override) |
| `LEXWARE_ENABLE_DRAFTS` | `true` | Enable create-draft tools |
| `LEXWARE_ENABLE_FINALIZE` | `false` | Enable finalize / legally-binding tools (also enables Drafts) |
| `LEXWARE_UPLOAD_ALLOWED_HOSTS` | `sharepoint.com,onedrive.live.com,1drv.ms,graph.microsoft.com` | Comma-separated hosts `upload-file-from-url` may download from (the host itself or any subdomain, matched on a dot boundary). **Replaces** the defaults rather than extending them, so you can opt out of them. Set it empty to block every host and effectively disable the tool |
| `LEXWARE_API_BASE_URL` | `https://api.lexware.io` | API base URL |
| `LEXWARE_APP_BASE_URL` | `https://app.lexware.de` | Web-app base for document deeplinks |
| `PORT` | `8080` | Listen port (your platform may inject this) |
Expand Down
67 changes: 66 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* any Skybridge/Express imports so it can be unit-tested in isolation.
*/

import { DEFAULT_ALLOWED_HOSTS } from "./uploads/fetch-url.js";

/** Minimum length for `MCP_AUTH_TOKEN`. A 32-hex-char token is 32 chars. */
export const MIN_TOKEN_LENGTH = 16;

Expand Down Expand Up @@ -63,6 +65,24 @@ export interface Config {
/** Web-app base for building document deeplinks, e.g. `https://app.lexware.de`. */
lexwareAppBaseUrl: string;
auth: AuthConfig;
/**
* This server's public base URL, used to build the `/upload/:ticket` links that
* `create-upload-ticket` hands to a browser and bakes into its curl command.
*
* Deliberately NOT part of {@link AuthConfig}: where this server is reachable is a
* deployment fact, not an auth one. Deriving it from the auth mode — OAuth resource
* or else loopback — meant a static-token deployment behind a real domain (a
* supported mode, see README) handed out `http://127.0.0.1:8080/upload/…`, a link
* that resolves nowhere but inside the container.
*/
publicBaseUrl: string;
/**
* Hosts `upload-file-from-url` may download from (the host itself or any subdomain).
* Defaults to {@link DEFAULT_ALLOWED_HOSTS}; `LEXWARE_UPLOAD_ALLOWED_HOSTS` REPLACES
* that list rather than extending it, so the defaults can be opted out of. An empty
* list blocks every host — fail closed, never "allow all".
*/
uploadAllowedHosts: string[];
port: number;
debugLogging: boolean;
capabilities: Capabilities;
Expand All @@ -74,6 +94,26 @@ const DEFAULT_BASE_URL = "https://api.lexware.io";
const DEFAULT_APP_BASE_URL = "https://app.lexware.de";
const DEFAULT_PORT = 8080;

/**
* Resolve the URL-upload host allow-list.
*
* Unset (the variable absent entirely) keeps {@link DEFAULT_ALLOWED_HOSTS}. Any other
* value REPLACES the defaults — extending them would make Microsoft's domains
* impossible to opt out of, which is the wrong default for a self-hosted server.
*
* An explicitly empty value therefore yields an empty list, which blocks every host and
* disables `upload-file-from-url` entirely. That is deliberate: an allow-list that
* cannot be emptied cannot be used to turn the feature off, and "empty means allow
* everything" would turn a typo into an open SSRF surface.
*/
function resolveUploadAllowedHosts(raw: string | undefined): string[] {
if (raw === undefined) return DEFAULT_ALLOWED_HOSTS;
return raw
.split(",")
.map((h) => h.trim().toLowerCase())
.filter(Boolean);
}

/** Parse a boolean env value. Accepts true/1/yes/on (case-insensitive). */
function parseBool(raw: string | undefined, fallback: boolean): boolean {
if (raw === undefined || raw.trim() === "") return fallback;
Expand Down Expand Up @@ -142,6 +182,28 @@ function validateIssuerUrl(raw: string): string {
return value;
}

/**
* Resolve this server's public base URL — in EVERY auth mode, not just OAuth.
*
* `OAUTH_RESOURCE` first, so an OAuth deployment can never drift from the value its
* token audience is built from; then `SERVER_URL`, which the README already documents
* as "this server's public URL" and which was previously read only inside the OAuth
* branch (a static-token or unauthenticated deployment set it and it was ignored).
* Only with neither set does the loopback fallback apply: correct for a local run,
* and honest about being unusable anywhere else.
*
* Validated through {@link normalizeUrl} like every other configured URL, so a typo
* or a plain-http public URL fails at startup rather than being pasted into a curl
* command an operator then runs.
*/
function resolvePublicBaseUrl(env: NodeJS.ProcessEnv, port: number): string {
const resource = env.OAUTH_RESOURCE?.trim();
if (resource) return normalizeUrl(resource, resource, "OAUTH_RESOURCE");
const serverUrl = env.SERVER_URL?.trim();
if (serverUrl) return normalizeUrl(serverUrl, serverUrl, "SERVER_URL");
return `http://127.0.0.1:${port}`;
}

/** Resolve how `/mcp` is authenticated, failing closed if nothing is configured. */
function resolveAuth(env: NodeJS.ProcessEnv): AuthConfig {
const issuerRaw = env.OAUTH_ISSUER?.trim();
Expand Down Expand Up @@ -239,6 +301,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
}

const auth = resolveAuth(env);
const port = parsePort(env.PORT);

const readOnly = parseBool(env.LEXWARE_READ_ONLY, false);
// READ_ONLY is a hard override: it wins over the individual enable flags.
Expand Down Expand Up @@ -266,7 +329,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
"LEXWARE_APP_BASE_URL",
),
auth,
port: parsePort(env.PORT),
publicBaseUrl: resolvePublicBaseUrl(env, port),
uploadAllowedHosts: resolveUploadAllowedHosts(env.LEXWARE_UPLOAD_ALLOWED_HOSTS),
port,
debugLogging: parseBool(env.LEXWARE_DEBUG_LOGGING, false),
capabilities: { read: true, drafts: enableDrafts, finalize: enableFinalize },
warnings,
Expand Down
Loading