{
  "markdown": "# @layervai/qurl-mcp\n\n[![npm version](https://img.shields.io/npm/v/@layervai/qurl-mcp.svg)](https://www.npmjs.com/package/@layervai/qurl-mcp)\n\n> **⚠️ Renamed from `@layerv/qurl-mcp` in v0.4.0.** The old package is deprecated and will not receive further updates. If you're using `@layerv/qurl-mcp@0.3.x`, swap the scope in your MCP client config — same binary, same API key, no other changes.\n\n> A qURL MCP Server that supports both local `stdio` mode and remote `HTTP` mode for creating, managing, resolving, and sharing secure access links.\n\n## Overview\n\n`qURL MCP` exposes qURL capabilities to MCP clients, GPTs, ChatGPT, and other remote integrations.\n\nIt currently supports:\n\n- creating, reading, updating, and deleting qURLs\n- resolving access tokens\n- managing qURL tokens and sessions\n- uploading text or file content and generating qURLs\n- serving public legal pages\n- serving a configurable MP4 video playback page\n\n## Runtime Modes\n\n| Mode    | Purpose                         | Start Command        | Typical Use Case                                           |\n| ------- | ------------------------------- | -------------------- | ---------------------------------------------------------- |\n| `stdio` | Local subprocess MCP server     | `npm run start`      | Claude Desktop, Cursor, Codex, and other local MCP clients |\n| `http`  | Authenticated remote MCP server | `npm run start:http` | Remote agent runtimes behind HTTPS                         |\n\n## Feature Map\n\n### qURL Management Tools\n\n| Tool                      | Description                                         |\n| ------------------------- | --------------------------------------------------- |\n| `create_qurl`             | Create a new qURL                                   |\n| `resolve_qurl`            | Resolve an access token into a protected target URL |\n| `list_qurls`              | List qURL resources                                 |\n| `get_qurl`                | Fetch details for a single qURL                     |\n| `delete_qurl`             | Delete a qURL                                       |\n| `extend_qurl`             | Extend qURL expiration                              |\n| `update_qurl`             | Update qURL metadata or expiration                  |\n| `mint_link`               | Mint a new access link for an existing resource     |\n| `batch_create_qurls`      | Create multiple qURLs in one request                |\n| `revoke_qurl_token`       | Revoke a specific token                             |\n| `update_qurl_token`       | Update a specific token                             |\n| `list_qurl_sessions`      | List active access sessions                         |\n| `terminate_qurl_sessions` | Terminate one or all active sessions                |\n\n### Upload Tools\n\n| Tool                    | Mode         | Description                                |\n| ----------------------- | ------------ | ------------------------------------------ |\n| `upload_file_qurl`      | `stdio`      | Upload a local file and mint a qURL        |\n| `upload_file_data_qurl` | `stdio`/HTTP | Upload base64 file content and mint a qURL |\n| `upload_text_qurl`      | `stdio`/HTTP | Upload text content and mint a qURL        |\n\n`upload_file_qurl` is intentionally stdio-only. It can read any supported\nPDF/image that the local MCP process user can access, so agents should invoke\nit only for a path the user explicitly selected for sharing. Do not expose it\nto untrusted prompts or autonomous agents: prompt injection could otherwise\nselect another readable PDF/image on the host. Run stdio under an OS account\nwhose filesystem access is limited to intended shareable content. HTTP mode\nnever registers this host-file tool.\nThe byte/text tools are also available in stdio so local clients can share\nin-chat attachments without first materializing them at a known host path.\nConnector upload and qURL minting are separate operations. If minting fails\nafter upload, the connector currently has no delete endpoint; the server logs\nthe orphaned `resource_id` for operator cleanup and returns the mint failure.\nHTTP upload attempts remain bounded by the per-IP and per-credential MCP rate\nlimits; stdio operators should separately constrain autonomous retry loops.\nUpload validation binds the declared media type to the filename plus format\nstart/end markers; it is not a malware scanner or full PDF/image decoder.\nFor polyglot resistance, a PDF's final `%%EOF` marker must be followed only by\nASCII whitespace; producer output with other trailing bytes is rejected even\nif a permissive PDF reader would accept it. JPEG validation checks framing and\nterminal markers rather than decoding image segments. The authenticated\nconnector must independently decode or otherwise fully validate content before\nstorage when semantic media validity matters. It must also preserve the\ndeclared safe media type and serve downloads with `X-Content-Type-Options:\nnosniff` rather than inferring an executable type.\nThere is intentionally no application-level path allowlist: symlinks and\ntime-of-check/time-of-use races make a lexical prefix check a misleading\nsecurity boundary. Use a dedicated OS account, container, or read-only mount\nwhose readable files are already limited to the intended sharing directory.\nThe final path component is opened with `O_NOFOLLOW`; intermediate directory\nsymlinks retain normal filesystem behavior under this trusted-local-user\nboundary.\n\n### MCP Resources\n\n| URI            | Description                         |\n| -------------- | ----------------------------------- |\n| `qurl://links` | Current qURL list                   |\n| `qurl://usage` | Current quota and usage information |\n\n### MCP Prompts\n\n| Prompt             | Description                       |\n| ------------------ | --------------------------------- |\n| `secure_a_service` | Secure service integration prompt |\n| `audit_links`      | Link audit prompt                 |\n| `rotate_access`    | Access rotation prompt            |\n\n## Quick Start\n\n### 1. Install Dependencies\n\n```bash\nnpm install\n```\n\nFor a local stdio-only source install, use `npm install --omit=optional`; this\nomits the AWS SDK. HTTP deployments using the DynamoDB credential quota must use\nthe ordinary install so the optional SDK is packaged.\n\n### 2. Build\n\n```bash\nnpm run build\n```\n\n### 3. Start\n\nLocal `stdio` mode:\n\n```bash\nnpm run start\n```\n\nRemote `HTTP` mode:\n\n```bash\nnpm run start:http\n```\n\n## MCP Client Example\n\nIf you want to use this server in `stdio` mode with a local MCP client:\n\n```json\n{\n  \"mcpServers\": {\n    \"qurl\": {\n      \"command\": \"npx\",\n      \"args\": [\"@layervai/qurl-mcp\"],\n      \"env\": { \"QURL_API_KEY\": \"lv_live_xxx\" }\n    }\n  }\n}\n```\n\n## Configuration Files\n\nCopy the tracked examples to create local configuration files:\n\n```bash\ncp qurl-mcp.config.example.json qurl-mcp.config.json\ncp qurl-mcp.http.example.json qurl-mcp.http.json\n```\n\nThe local files are gitignored so credentials and machine-specific paths are\nnot committed.\n\nTheir responsibilities are:\n\n| File                   | Purpose                                                     |\n| ---------------------- | ----------------------------------------------------------- |\n| `qurl-mcp.config.json` | Shared runtime config used by both `stdio` and `http` modes |\n| `qurl-mcp.http.json`   | HTTP-only server listener and public access config          |\n\n## qurl-mcp.config.json Reference\n\n### Shared Core Settings\n\n| Field                     | Purpose                                                |\n| ------------------------- | ------------------------------------------------------ |\n| `maxUploadFileDataBytes`  | Limits decoded and local file uploads (default `10mb`) |\n| `defaultQurlApiUrl`       | Base URL of the qURL backend API                       |\n| `defaultQurlConnectorUrl` | Base URL of the upload connector                       |\n\nShared settings have these environment overrides. Environment values take\nprecedence over the shared config file.\nThe process caches resolved shared settings but automatically invalidates that\ncache when the file metadata or any relevant environment value changes.\n\n| Environment variable                   | Config field                   |\n| -------------------------------------- | ------------------------------ |\n| `MCP_MAX_UPLOAD_FILE_DATA_BYTES`       | `maxUploadFileDataBytes`       |\n| `QURL_API_URL`                         | `defaultQurlApiUrl`            |\n| `QURL_CONNECTOR_URL`                   | `defaultQurlConnectorUrl`      |\n| `QURL_SMTP_HOST`                       | `smtp.host`                    |\n| `QURL_SMTP_PORT`                       | `smtp.port`                    |\n| `QURL_SMTP_SECURE`                     | `smtp.secure`                  |\n| `QURL_SMTP_USERNAME`                   | `smtp.username`                |\n| `QURL_SMTP_PASSWORD`                   | `smtp.password`                |\n| `QURL_SMTP_FROM_EMAIL`                 | `smtp.fromEmail`               |\n| `QURL_SMTP_FROM_NAME`                  | `smtp.fromName`                |\n| `QURL_SMTP_ALLOWED_RECIPIENTS`         | `smtp.allowedRecipients`       |\n| `QURL_SMTP_ALLOWED_RECIPIENT_DOMAINS`  | `smtp.allowedRecipientDomains` |\n| `QURL_SMTP_MAX_RECIPIENTS_PER_MESSAGE` | `smtp.maxRecipientsPerMessage` |\n| `QURL_SMTP_MAX_RECIPIENTS_PER_HOUR`    | `smtp.maxRecipientsPerHour`    |\n| `QURL_PUBLIC_VIDEO_FILE_PATH`          | `publicVideo.filePath`         |\n| `QURL_PUBLIC_VIDEO_TITLE`              | `publicVideo.title`            |\n| `QURL_PUBLIC_VIDEO_PAGE_PATH`          | `publicVideo.pagePath`         |\n\n`QURL_API_KEY` is intentionally environment-only and has no config-file field.\nPrefer `QURL_SMTP_PASSWORD` for the SMTP secret as well. If `smtp.password` is\nstored in the config file on a POSIX host, restrict that file to owner-only\npermissions (for example, `chmod 600`); startup warns when group/other read bits\nare present. This check is intentionally advisory so existing deployments do\nnot fail after an upgrade, and it is skipped on Windows because POSIX mode bits\nare not available there.\nEmail delivery itself is fail-closed unless at least one exact\n`smtp.allowedRecipients` entry or `smtp.allowedRecipientDomains` entry is\nconfigured; startup warns when complete SMTP credentials lack that policy.\n\nRaising `maxUploadFileDataBytes` also raises the HTTP JSON parser's per-request\nmemory ceiling to roughly 1.5 times that value (up to about 150 MB at the\n100 MB maximum), before base64 decoding applies the exact byte cap. Until a\nsession has completed a successful downstream qURL API call, its parser ceiling\nremains at the smaller 10 MB default upload setting; clients configured for a\nlarger first upload must validate the session with a small qURL API call first.\nSize the configured maximum and reverse-proxy concurrency limit together.\n\nSet `QURL_API_KEY` in the environment for `stdio` mode. In HTTP mode, every\nclient request supplies its own qURL API key as a bearer token.\n\n`defaultQurlApiUrl` and `QURL_API_URL` require HTTPS for non-loopback hosts\nbecause qURL API keys and data are bearer-sent to that destination. Plain HTTP\nis accepted only for literal loopback development endpoints. Upload connector\nURLs follow the same HTTPS-except-loopback rule.\nLoopback means `127.0.0.0/8` or `::1`; wildcard bind addresses such as\n`0.0.0.0` and `::` are intentionally not accepted as outbound HTTP targets.\nConnector destinations are trusted operator configuration rather than caller\ninput; private addresses and DNS resolution are therefore permitted. Pin the\nconnector hostname in deployment DNS and do not point it at metadata services.\nThe caller's qURL bearer credential is forwarded to this host, so treat connector\nURL and DNS control as part of the credential trust boundary.\nConfigure the connector service base URL, not an upload route: qurl-mcp appends\n`/api/upload` to ordinary base paths, accepts that exact endpoint suffix, and\nrejects ambiguous upload-like paths such as `/upload` or `/api/upload/v2`.\nThe MCP server performs bounded file-framing checks, not full media parsing;\nthe connector must independently revalidate uploaded content before storage or\nserving, and delivery must retain `nosniff` behavior as the authoritative type\nboundary.\n\nAPI and connector base URLs that contain embedded credentials, a query string,\nor a fragment are now rejected during startup. Deployments that previously used\none of those unusual URL forms must move credentials to `QURL_API_KEY` and keep\nthe configured service URL to its origin and optional path prefix.\n\n### SMTP Settings\n\n| Field                          | Purpose                                                                      |\n| ------------------------------ | ---------------------------------------------------------------------------- |\n| `smtp.host`                    | SMTP server hostname                                                         |\n| `smtp.port`                    | SMTP server port                                                             |\n| `smtp.secure`                  | `true` for implicit TLS; `false` for required STARTTLS                       |\n| `smtp.username`                | SMTP login username                                                          |\n| `smtp.password`                | SMTP login password or app-specific code                                     |\n| `smtp.fromEmail`               | Sender email address                                                         |\n| `smtp.fromName`                | Sender display name                                                          |\n| `smtp.allowedRecipients`       | Optional exact-address allowlist                                             |\n| `smtp.allowedRecipientDomains` | Optional exact-domain allowlist (subdomains are not included)                |\n| `smtp.maxRecipientsPerMessage` | Per-message recipient cap (default `10`)                                     |\n| `smtp.maxRecipientsPerHour`    | Per-qURL-key attempted-recipient cap per fixed hourly window (default `100`) |\n\nThese settings are used when email delivery is requested by tools such as:\n\n- `create_qurl`\n- `mint_link`\n- `upload_text_qurl`\n- `upload_file_qurl`\n- `upload_file_data_qurl`\n\nIf either recipient allowlist is configured, only an exact address or domain\nmatch is delivered. If both are empty, the message and hourly caps still apply.\nDomain entries are exact: `example.com` does not implicitly allow\n`mail.example.com`; list each permitted subdomain explicitly.\nAddresses and domains are normalized to lowercase NFC/IDNA ASCII form and a\ntrailing DNS root dot is removed before comparison and delivery.\nEach recipient allowlist is limited to 1,000 configured entries. The\nper-message recipient cap applies to the complete unique requested fan-out\nbefore allowlist filtering, so blocked addresses cannot be used to submit an\noversized batch.\nIn HTTP mode, any caller with a valid qURL API key can request a server-side\nSMTP delivery. Configure `allowedRecipients` or `allowedRecipientDomains`\nbefore enabling SMTP on an Internet-facing HTTP deployment; empty allowlists\npermit delivery to any syntactically valid address subject to the quotas.\nThe SMTP transport uses bounded connection/socket timeouts and is closed after\neach delivery batch. Failed SMTP attempts still consume quota—including when a\ntransient outage results in zero delivered messages—so repeated failures cannot\nbypass the abuse limit.\nEach delivery request also has a 60-second aggregate deadline. Recipients not\nstarted before that deadline are reported as skipped; provider-side queues are\nthe supported path for larger or slower fan-out.\nTransport encryption is mandatory: `smtp.secure: true` uses implicit TLS,\nwhile `smtp.secure: false` requires a successful STARTTLS upgrade. Port 465 is\nreserved for implicit TLS and therefore requires `smtp.secure: true`.\nHourly quota state is maintained per server process: it resets on restart and\nis not shared across replicas. Operators running multiple instances should\nenforce a corresponding aggregate limit at the SMTP provider or gateway.\nThe in-process quota is therefore an abuse backstop, not a durable global\nsafety boundary; restart/scale-out fail-open behavior must be covered by that\nprovider-side limit.\nTracking fails closed for new principals after 10,000 principals are retained\nin one process; existing principals continue to use their current buckets until\nexpired entries are pruned.\nRestrict qURL API-key issuance and monitor new-principal quota-cap rejections:\ncycling many valid keys can deliberately hold that shared table at capacity for\nup to one quota window.\nThe quota uses a fixed one-hour window that starts with the first attempted\ndelivery after the prior window expires.\nAs with any fixed window, traffic immediately before and after a boundary can\ntotal nearly twice the configured hourly value; use a provider-side sliding or\nrolling limit when that boundary burst must be prevented across replicas.\nGenerated qURL links are included in the plain-text email body. Restrict\nrecipients with the SMTP allowlists and configure transport encryption at the\nSMTP server/provider when link confidentiality matters.\n\nPrefer environment variables for SMTP credentials and policy:\n`QURL_SMTP_USERNAME`, `QURL_SMTP_PASSWORD`, `QURL_SMTP_FROM_EMAIL`,\n`QURL_SMTP_ALLOWED_RECIPIENTS`, `QURL_SMTP_ALLOWED_RECIPIENT_DOMAINS`,\n`QURL_SMTP_MAX_RECIPIENTS_PER_MESSAGE`, and\n`QURL_SMTP_MAX_RECIPIENTS_PER_HOUR`.\n\n### Public Video Page Settings\n\n| Field                  | Purpose                                |\n| ---------------------- | -------------------------------------- |\n| `publicVideo.title`    | Title shown on the public video page   |\n| `publicVideo.pagePath` | Public path of the video playback page |\n| `publicVideo.filePath` | Absolute server path of the MP4 file   |\n\nWhen configured, the HTTP server additionally exposes:\n\n- a public video playback page\n- a streaming endpoint for the MP4 file\n\n`publicVideo.filePath` is trusted operator configuration. The final component\nmust be a non-symlink regular `.mp4` file; intermediate directory symlinks keep\nnormal filesystem resolution and must therefore remain under operator control.\nStartup probes this optional asset and warns when it is missing, empty, or not\nregular, but intentionally keeps the MCP service and `/healthz` available. The\nvideo-file route still fails closed with `404` until the asset is corrected.\n\n## qurl-mcp.http.json Reference\n\nUse [`qurl-mcp.http.example.json`](qurl-mcp.http.example.json) for local,\nstateful development. [`qurl-mcp.http.stateless.example.json`](qurl-mcp.http.stateless.example.json)\nshows every store and metric field required by a deployed stateless service.\n\n| Field                          | Purpose                                                                             |\n| ------------------------------ | ----------------------------------------------------------------------------------- |\n| `port`                         | HTTP MCP listener port                                                              |\n| `host`                         | HTTP MCP bind address                                                               |\n| `baseUrl`                      | Public base URL of the service                                                      |\n| `allowedHosts`                 | Host allowlist for Host header validation                                           |\n| `trustProxyHops`               | Exact trusted reverse-proxy hop count (default `0`)                                 |\n| `stateless`                    | Request-scoped HTTP transport with no session affinity (default `false`)            |\n| `maxConcurrentRequests`        | Stateless-only POST/parser concurrency cap per process (default `20`)               |\n| `credentialRateLimitStore`     | Credential counter backend: `memory` or `dynamodb` (default `memory`)               |\n| `rateLimitDynamoDbTable`       | DynamoDB table used by the shared credential counter                                |\n| `metricsNamespace`             | CloudWatch EMF namespace for stateless saturation metrics                           |\n| `metricsService`               | Stable CloudWatch EMF Service dimension                                             |\n| `metricsEnvironment`           | Stable CloudWatch EMF Environment dimension                                         |\n| `maxSessions`                  | Hard cap on live MCP sessions (default `1000`)                                      |\n| `maxSessionsPerCredential`     | Per-bearer live and initializing session cap (default `20`)                         |\n| `maxUnvalidatedSessions`       | Cap on sessions that have not completed a downstream qURL API call (default `100`)  |\n| `sessionIdleTtlMs`             | Connected-session idle eviction window (default 15 minutes)                         |\n| `sessionAbsoluteTtlMs`         | Absolute session lifetime, including active SSE/tool requests (default 24 hours)    |\n| `unvalidatedSessionTtlMs`      | Absolute validation deadline for never-validated bearer sessions (default 1 minute) |\n| `mcpRateLimitPerMinute`        | Per-client `/mcp` request limit (default `120`)                                     |\n| `publicFileRateLimitPerMinute` | Per-client public-route request limit (default `300`)                               |\n\nHTTP fields have matching environment overrides:\n\n| Environment variable                    | Config field                      |\n| --------------------------------------- | --------------------------------- |\n| `MCP_PORT`                              | `port`                            |\n| `MCP_HOST`                              | `host`                            |\n| `MCP_BASE_URL`                          | `baseUrl`                         |\n| `MCP_ALLOWED_HOSTS`                     | `allowedHosts`                    |\n| `MCP_TRUST_PROXY_HOPS`                  | `trustProxyHops`                  |\n| `MCP_HTTP_STATELESS`                    | `stateless`                       |\n| `MCP_MAX_CONCURRENT_REQUESTS`           | `maxConcurrentRequests`           |\n| `MCP_CREDENTIAL_RATE_LIMIT_STORE`       | `credentialRateLimitStore`        |\n| `MCP_RATE_LIMIT_DYNAMODB_TABLE`         | `rateLimitDynamoDbTable`          |\n| `MCP_METRICS_NAMESPACE`                 | `metricsNamespace`                |\n| `MCP_METRICS_SERVICE`                   | `metricsService`                  |\n| `MCP_METRICS_ENVIRONMENT`               | `metricsEnvironment`              |\n| `MCP_MAX_SESSIONS`                      | `maxSessions`                     |\n| `MCP_MAX_SESSIONS_PER_CREDENTIAL`       | `maxSessionsPerCredential`        |\n| `MCP_MAX_UNVALIDATED_SESSIONS`          | `maxUnvalidatedSessions`          |\n| `MCP_SESSION_IDLE_TTL_MS`               | `sessionIdleTtlMs`                |\n| `MCP_SESSION_ABSOLUTE_TTL_MS`           | `sessionAbsoluteTtlMs`            |\n| `MCP_UNVALIDATED_SESSION_TTL_MS`        | `unvalidatedSessionTtlMs`         |\n| `MCP_RATE_LIMIT_PER_MINUTE`             | `mcpRateLimitPerMinute`           |\n| `MCP_PUBLIC_FILE_RATE_LIMIT_PER_MINUTE` | `publicFileRateLimitPerMinute`    |\n| `MCP_MAX_UPLOAD_FILE_DATA_BYTES`        | `maxUploadFileDataBytes` (shared) |\n\nThe listener defaults to `127.0.0.1`. A non-loopback `host` is rejected unless\n`allowedHosts` is explicitly configured. Set `trustProxyHops` (or\n`MCP_TRUST_PROXY_HOPS`) to the exact number of trusted proxy hops; leave it at\n`0` for direct connections so forwarded IP headers cannot spoof rate-limit keys.\nThe Host allowlist is limited to 1,000 entries so request-time validation stays\nbounded even under pathological operator configuration.\n`/mcp` applies the configured request allowance independently to both the\nclient IP and the SHA-256 digest of the authenticated bearer. The memory store\nis process-local; the DynamoDB store uses an atomic fixed-window counter keyed\nby credential digest and UTC minute. It never stores the bearer. As with any\nfixed window, requests around a minute boundary can total nearly twice the\nconfigured allowance. The table contract is a string partition key named\n`rate_key`; the atomic update writes a numeric `request_count` counter and a\nnumeric `expires_at` TTL timestamp. Enable DynamoDB TTL on `expires_at` so\nexpired rows do not accumulate; TTL only schedules asynchronous cleanup, and\nthe minute in the key—not physical deletion—resets the active window. The task\nrole requires `dynamodb:DescribeTable` for startup and `dynamodb:UpdateItem` on\nthe request path. Use on-demand capacity or provision enough write capacity for\nthe expected fleet rate; throttling fails closed with `503` and never falls\nback to memory. The client uses standard retry mode with at most two attempts,\na one-second connection timeout, and a two-second request timeout that throws;\nthese explicit bounds limit how long a request holds a concurrency permit\nduring a partial store failure. The optional AWS SDK dependency is top-level\nexact-version pinned, while the committed package lock fixes its transitive\n`@aws-sdk/*` and `@smithy/*` graph. Any SDK bump must update the lockfile and\nkeep the real-`NodeHttpHandler` timeout-materialization regression test green.\nThe dependency is loaded only when the DynamoDB store is selected, so\nstdio-only consumers may install with `--omit=optional`. Deployed HTTP images\nmust include optional dependencies; startup fails before listening if the SDK\nis absent or exposes an incompatible runtime surface. The client uses the\nstandard `AWS_REGION` and credential provider chain; ECS deployments\nnormally obtain both from the task environment and task role. Reverse-proxy\ndeployments must set the correct hop count or all callers behind the proxy will\nshare the proxy's single IP bucket. Only the DynamoDB credential quota is\nfleet-wide: the IP limiter is process-local, so its effective fleet allowance\nmultiplies with task count and must be backed by a shared edge limit. The\nmanaged deployment in qurl-integrations-infra PR #1305 enforces both a\nper-source-IP WAF limit and a lower aggregate `/mcp` fleet cap, with live\nheadroom proof tracked in issue #1306. The credential bucket also prevents one\nkey from bypassing the request allowance by rotating source IPs, while\n`maxSessionsPerCredential` prevents it from occupying the full session pool.\nEach distinct bearer value retains one credential-bucket entry for the current\none-minute window. The IP limiter runs first, so token rotation from one source\ncannot create entries faster than `mcpRateLimitPerMinute`; hostile distributed\ntraffic still requires the documented shared edge limit. The IP bucket is the\nprimary in-process control against arbitrary bearer rotation because distinct\nunvalidated bearer strings necessarily occupy distinct credential buckets.\nIn stateful mode, budget pending-session parser memory as\n`maxUnvalidatedSessions` times roughly\n1.5 times the smaller of `maxUploadFileDataBytes` and 10 MB (plus about 64 KiB\nper request). At the defaults, the theoretical concurrent ceiling is about\n1.5 GiB. Lower `maxUnvalidatedSessions` and the shared edge concurrency limit\ntogether when the deployment has a smaller memory budget.\nBearer credentials are conclusively validated by the first successful\ndownstream qURL API call. Until then, sessions use the smaller pending-session\ncap and one-minute validation deadline, so arbitrary non-empty bearer strings\ncannot occupy the full session pool for the normal 15-minute TTL. A client that\nperforms only MCP introspection remains pending by design; after deadline\neviction it must re-initialize before its next request. The session caps and\nvalidation deadline are configurable for clients with longer\nintrospection-to-tool-call gaps. The deadline is absolute and applies regardless\nof activity, including an open SSE stream or a long-running first tool call.\nValidated clients that disconnect without sending `DELETE /mcp` retain their\nbounded session slot for a 30-second reconnect grace period. A reconnect clears\nthat deadline; otherwise the session is reaped without waiting for the longer\nidle TTL. Size `maxSessions` and the idle TTL for clients that remain connected\nbut do not perform explicit session teardown.\nValidated sessions also expire at `sessionAbsoluteTtlMs` (24 hours by default),\neven during an active SSE stream or tool request. This prevents keepalives from\npinning a global or per-credential session slot indefinitely.\nThe first downstream qURL operation must therefore complete before that\ndeadline; an unusually slow first API call may be interrupted and the client\nmust re-initialize. This fail-closed behavior prevents an invalid credential\nfrom extending its pending slot with a deliberately long-running request.\nAccepting a non-empty bearer during MCP initialization is intentional: it keeps\nprotocol introspection available before the first qURL operation, while the\nglobal session cap, per-credential session cap, pending-session cap, absolute\ndeadline, and request rate limit bound invalid-key slot usage. The MCP\nmiddleware does not validate the key itself; only a successful downstream qURL\nAPI response promotes the session.\nDownstream errors, including non-2xx responses that appear authenticated, do\nnot promote it because an intermediary may have generated them before the qURL\nAPI authenticated the bearer.\nPromotion therefore assumes the configured HTTPS qURL API endpoint and every\ntrusted intermediary neither cache nor synthesize authenticated success\nresponses. Reverse proxies in that path must forward authorization and disable\nresponse caching for qURL API traffic.\nConsequently, any caller with a non-empty bearer can enumerate the public\ntool/resource/prompt catalog and briefly hold bounded pending-session state. On\nhostile networks, place non-loopback deployments behind an identity-aware proxy\nthat preserves the caller's qURL bearer credential for `/mcp` authorization.\nInitialization and catalog listing return server-owned static metadata only;\nthey do not invoke tool/resource/prompt handlers, read host files, contact the\nqURL API or connector, or send email. Handler calls rely on the configured qURL\nAPI to authenticate the forwarded bearer before returning data or applying an\noperation. The configured connector is a second credential authority: it must\nauthenticate the forwarded qURL bearer before accepting or storing upload bytes.\nDeploying an unauthenticated connector is unsupported because it would allow an\nunvalidated MCP caller to create connector-side state.\n\nStateful mode is the compatibility default and retains the existing MCP session\nregistry, GET SSE, explicit DELETE behavior, and process-local credential quota\ncharging for all three MCP methods. Stateless mode creates and closes a server\nand transport for each POST, ignores `mcp-session-id`, and\nreturns JSON-RPC-shaped `405` responses for GET and DELETE. It is the required\nmode behind a load balancer or autoscaling service because no request depends\non process-local affinity. The concurrency permit is acquired before JSON\nparsing and released on every response/error/disconnect path. Stateless mode\nuses the configured `maxUploadFileDataBytes` parser ceiling directly because\nthe pre-parse concurrency permit provides its memory-amplification bound.\nBudget roughly `maxConcurrentRequests` times (1.5 times\n`maxUploadFileDataBytes` plus 64 KiB) per process; the default concurrency at\nthe 100 MB upload ceiling is approximately 3 GiB before downstream work.\nStateless startup rejects configurations whose conservative parser budget\nexceeds 4 GiB. Lower either setting further when the ECS task has a smaller\nmemory limit. In contrast, stateful sessions above the default ceiling must\nfirst complete a successful downstream qURL API call.\nOn hostile networks, an authenticated edge request-size limit no larger than\nthe configured parser ceiling is a deployment requirement: the permit bounds\naggregate memory, but a non-empty bearer is not authoritatively validated until\nthe parsed operation reaches the downstream qURL API.\n\nThe stateless listener bounds header receipt at 15 seconds and both complete\nrequest receipt and idle socket lifetime at 120 seconds. A concurrency permit\nspans parsing through response completion, so stalled clients cannot retain the\nentire permit pool indefinitely. A tool call that produces no socket traffic\nfor 120 seconds is intentionally aborted; integrations needing longer silent\noperations must move that work behind an asynchronous API rather than raising\nthis fleet-wide retention bound.\n\nDeployed (non-loopback) stateless mode requires the DynamoDB credential store\nand all three stable metric identity fields. It emits a 30-second EMF heartbeat:\n`McpConcurrencyUtilization` is the peak permit utilization observed during the\ninterval at request admission and heartbeat (including requests that start and\nfinish between heartbeats), while\n`McpConcurrencyRejected` and `McpRateLimitStoreErrors` are snapshot-and-zero\ninterval deltas that include explicit zeros. Session caps and email recipient\nquotas remain in-memory; the DynamoDB credential quota is fleet-wide and counts\nevery authenticated HTTP POST, including initialization, discovery, and tool\ncalls. Size that quota for the expected complete request pattern rather than\ntool calls alone. The permit also spans the bounded DynamoDB increment: during\na store brownout, each admitted request may retain one permit for roughly four\nseconds (two two-second attempts) before failing closed, while excess requests\nreceive a fast concurrency `503`. The fixed-window counter increments every\nattempt, including\nattempts already above the credential limit; edge rate limits and DynamoDB\nwrite/throttle alarms must therefore bound abusive write amplification.\nDeployment owners must make both alarms and an over-limit write-amplification\nprobe hard promotion gates rather than treating them as optional observability.\nThe managed deployment in\n[qurl-integrations-infra#1305](https://github.com/layervai/qurl-integrations-infra/pull/1305)\nprovisions those alarms, with live proof tracked in its rollout ledger and\nissue #1306 before promotion.\nDirect `createHttpRuntime` embedders that inject a credential-store\nimplementation must still declare `credentialRateLimitStore: \"dynamodb\"` for\nnon-loopback stateless mode. The generic injection interface cannot prove a\ncustom backend is shared across replicas, so injection is deliberately not an\nescape hatch from the deployed contract.\nMetric identity fields are rejected in stateful mode so the concurrency gauge\ncannot silently report a misleading zero.\nEach stateless POST owns a fresh MCP server and transport so no request can\ninherit another credential's handler state. Completed-response teardown is\ntracked asynchronously. Admission stops when that backlog reaches\n`maxConcurrentRequests`; requests already in flight may then finish, so the\nbacklog can transiently approach twice that count but remains bounded. While\nthe admission guard is closed, new requests fail with `503` and increment\n`McpConcurrencyRejected` instead of growing teardown memory without bound.\nThat counter intentionally represents admission failure from either active\nrequest saturation or teardown backpressure. Autoscaling must use\n`McpConcurrencyUtilization` alone; the rejection counter remains page-worthy,\nand low utilization alongside rejections identifies teardown lag.\nPooling these objects would weaken request isolation and is deliberately not a\nperformance optimization without measured registration pressure.\n`/healthz` and the public video-file endpoint each use their own\n`publicFileRateLimitPerMinute` bucket, isolated from legal/video-page traffic\nand from each other. Keep load-balancer, liveness-probe, and expected video\nrange-request frequency below that per-source-IP allowance (300\nrequests/minute by default), or raise it for unusually aggressive clients.\n\n## Configuration Priority\n\nBy default, configuration is loaded from the two local JSON files above. If a\nfile is absent, built-in defaults and environment variables are used.\nRelative config paths—including the defaults—are resolved from the process\nworking directory. Set the explicit path variables below when a supervisor,\n`npx`, or an MCP host launches the server from a different directory.\n\nThe following environment variables independently override the config file paths:\n\n- `QURL_MCP_CONFIG`\n- `QURL_MCP_HTTP_CONFIG`\n\n`QURL_MCP_HTTP_CONFIG` never replaces the shared runtime config path. This keeps\nlistener settings from silently shadowing SMTP, connector, or API settings.\n\n`server.json` and `smithery.yaml` describe the published stdio transport, so\nthey include shared upload/SMTP settings but intentionally omit HTTP-only\nlistener variables such as `QURL_MCP_HTTP_CONFIG` and `MCP_MAX_SESSIONS`.\n\nDo not commit API keys, SMTP credentials, or private file-system paths.\n\n## HTTP Routes\n\nAfter starting in `http` mode, the common routes are:\n\n| Route                          | Purpose                      |\n| ------------------------------ | ---------------------------- |\n| `/mcp`                         | Main remote MCP endpoint     |\n| `/healthz`                     | Health check endpoint        |\n| `/legal/privacy`               | Public privacy policy page   |\n| `/legal/terms`                 | Public terms of service page |\n| `publicVideo.pagePath`         | Public video playback page   |\n| `publicVideo.pagePath + /file` | MP4 streaming endpoint       |\n\n`/healthz` is intentionally unauthenticated and Host-unvalidated for every\ncaller, exposes only `{ \"ok\": true }`, and uses the configured public-route\nrequest limit in a separate bucket so health probes cannot consume the\nlegal/video route allowance. A `429` from this route\nmeans the probe source exceeded `publicFileRateLimitPerMinute`, not that the\napplication failed its liveness check; keep probe frequency below that limit.\nIt is registered before Host validation because ALB target probes use the task\nIP and port as Host; public MCP and browser routes remain Host-validated.\n\n## HTTP Authentication\n\nThe `/mcp` endpoint requires `Authorization: Bearer <qURL API key>` on every\nrequest. In stateful mode the bearer token is bound to the resulting MCP\nsession, so a session ID cannot be reused with a different credential. In\nstateless mode the bearer remains request-scoped and is discarded when the\nresponse closes.\n\n**Operator authentication boundary:** initialization accepts any non-empty\nbearer token and allows the public tools/resources/prompts catalog to be read\nbefore authoritative validation by the first downstream qURL API call.\nThat catalog is assembled from static schemas and descriptions and does not\ninclude bearer tokens, SMTP credentials, or other operator configuration.\nUnvalidated-session caps, a short validation deadline, and request rate limits\nbound that pre-validation state; the supplied token is forwarded only to the\nconfigured qURL API.\nIntrospection-only sessions therefore remain unvalidated and are closed at\n`unvalidatedSessionTtlMs`; clients can re-initialize if they need a longer-lived\nsession. A session is promoted only after a successful qURL API call—rejected\nor rate-limited calls do not prove the credential valid. Disconnected sessions\nremain registered for a 30-second SSE reconnect grace period, while\n`maxSessions` and `maxSessionsPerCredential` bound that allowance under churn.\n\nRequests without an `Origin` header are accepted for non-browser MCP clients.\nWhen `Origin` is present, it must match the origin of `baseUrl`; malformed or\ncross-origin values are rejected on `/mcp`. Public health, legal, and configured\nvideo routes do not use browser-origin state and are not gated by this check.\n\nConfigure remote MCP clients with:\n\n| Setting        | Value                             |\n| -------------- | --------------------------------- |\n| MCP Server URL | Your public HTTPS URL plus `/mcp` |\n| Authentication | Bearer token                      |\n| Token          | The caller's qURL API key         |\n\nIf a client only supports OAuth discovery, place an OAuth-compatible gateway\nin front of this server rather than exposing `/mcp` without authentication.\n\n## How to Verify Deployment\n\n### Service-Level Checks\n\nStart with:\n\n- `/healthz`\n- `/mcp`\n\n### Public Page Checks\n\nAlso verify the legal pages and, when configured, the video page:\n\n- `/legal/privacy`\n- `/legal/terms`\n- the configured public video page path\n\n### Domain Verification\n\nIf you plan to use OpenAI Platform, make sure the following root-level path exists:\n\n```text\n/.well-known/openai-apps-challenge\n```\n\n> This verification file must live under the domain root `.well-known` path, not under `/mcp`.\n\n## Docker\n\nThe repository includes a Dockerfile for containerized deployment.\n\nExample:\n\n```bash\ndocker build -t qurl-mcp .\ndocker run -i -e QURL_API_KEY=lv_live_xxx qurl-mcp\n```\n\nIf you deploy with Docker, make sure the container can still access the correct config files, or override the config file paths with environment variables.\n\nRun HTTP mode locally in Docker:\n\nThe image defaults to the stdio entry point and the HTTP server defaults to\ncontainer-local loopback. HTTP deployments must override the command and bind\nto `0.0.0.0` with an explicit Host allowlist:\n\n```bash\ndocker run --rm -p 3000:3000 \\\n  -e MCP_HOST=0.0.0.0 \\\n  -e MCP_ALLOWED_HOSTS=127.0.0.1,localhost \\\n  qurl-mcp node dist/http.js\n```\n\nFor a single trusted production reverse proxy, set\n`MCP_TRUST_PROXY_HOPS=1`, use the public HTTPS origin in `MCP_BASE_URL`, and\nset `MCP_ALLOWED_HOSTS` to the public hostname. Do not expose the container's\nlistener directly when proxy trust is enabled.\n\n## Common Commands\n\n| Command                 | Purpose               |\n| ----------------------- | --------------------- |\n| `npm run build`         | Compile TypeScript    |\n| `npm test`              | Run tests             |\n| `npm run test:coverage` | Run enforced coverage |\n| `npm run lint`          | Run ESLint            |\n| `npm run dev`           | TypeScript watch mode |\n| `npm run format`        | Format source code    |\n| `npm run format:check`  | Check formatting      |\n| `npm run start`         | Start stdio mode      |\n| `npm run start:http`    | Start HTTP mode       |\n\n## Recommended Deployment Order\n\n1. Copy and update the two example config files\n2. Set credentials through environment variables\n3. Run `npm install`\n4. Run `npm run build`\n5. Run `npm run start:http`\n6. Verify `/healthz`\n7. Verify unauthenticated `/mcp` requests receive `401`\n8. Configure the HTTPS reverse proxy\n9. Verify an authenticated MCP initialization and the optional public pages\n\n## Third-Party Assets\n\nText-to-PDF generation bundles the 17.8 MB Noto Sans SC variable font for\noffline multilingual glyph coverage. This intentionally increases the npm\ntarball to roughly 11.4 MB and the unpacked package to roughly 18.4 MB for all\ninstalls, including deployments that do not enable PDF workflows. Shipping the\nfont in-package avoids a runtime network dependency and preserves predictable\nCJK rendering; operators prioritizing a smaller install can remove the asset\nand accept the documented Helvetica fallback with limited CJK coverage. Its SIL\nOpen Font License and copyright notice are included in `assets/fonts/OFL.txt`.\n\n## License\n\nMIT -- [LayerV AI](https://layerv.ai)\n",
  "bytes": 43532,
  "sha": "7b13d6c8290f4d6d1e72987c9431a3b4e8c020acdce227b690545a4f77f5b6b1",
  "repo_slug": "layervai/qurl-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_layervai_qurl_mcp_05f0a93b/readme"
}