npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@uvaresearch/wrapper-mcp

v0.4.0

Published

Stdio MCP server for QMS Wrapper task management (tools, resources, prompts).

Readme

@uvaresearch/wrapper-mcp

Stdio MCP server for QMS Wrapper. Lets MCP-capable clients (Claude Code, Claude Desktop, Cursor, generic stdio hosts) drive a QMS Wrapper instance over HTTPS using a Personal Access Token (PAT). Communication is JSON-RPC over stdin/stdout; outbound traffic is HTTPS to a single configured origin.

The server is built domain-by-domain. It ships task/form management and document storage — 11 task-form.* tools, 3 project/organization lookup tools, 17 storage.* tools, 5 lookup / identity resources, 3 task/form resource templates, and 5 prompts. A task and a form are the same underlying entity (forms are the custom tracker), so every task-form.* tool handles both. Which tools a given host actually sees depends on the PAT's scopes — the server hides any tool the token can't reach (see below). Subsequent releases will expand to additional QMS Wrapper domains (processes, risk, etc.) under the same package and PAT-auth model.

Install

Via npx (no install)

Use this in your MCP host configuration (see Step 2 below). npx will download the package on first run and cache it; subsequent runs reuse the cache.

npx -y @uvaresearch/wrapper-mcp

Via npm install -g

For users who want wrapper-mcp on PATH:

npm install -g @uvaresearch/wrapper-mcp
wrapper-mcp --help

The binary lands in your global npm bin dir. Find it with:

npm bin -g

On macOS/Linux this is typically /usr/local/bin or ~/.npm-global/bin; on Windows it is %AppData%\npm.

Configure

Step 1: get a Personal Access Token

  1. Log in to your QMS Wrapper instance.
  2. Go to Profile -> Access Tokens -> Create token.
  3. Name it for the device it will live on (e.g. "Claude Desktop laptop").
  4. Pick scopes. Defaults cover read/write/create on tasks. Tick attachments:upload if you need file uploads, plus whichever extra scope your wrapper instance requires for reassignment.
  5. Submit and copy the wrapper_<32 chars> plaintext that is shown exactly once. Store it in your OS keychain or a password manager.

Step 2: configure your MCP host

WRAPPER_BASE_URL is the URL you use to sign in to QMS Wrapper in the browser (the user-facing app). For the hosted SaaS that is https://app.qmswrapper.com. Self-hosted instances use their own host.

Claude Code

Either project-scoped (.mcp.json in repo root) or user-scoped (~/.claude.json under the mcpServers key):

{
  "mcpServers": {
    "wrapper": {
      "command": "npx",
      "args": ["-y", "@uvaresearch/wrapper-mcp"],
      "env": {
        "WRAPPER_PAT": "wrapper_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "WRAPPER_BASE_URL": "https://app.qmswrapper.com"
      }
    }
  }
}

Claude Desktop

Edit claude_desktop_config.json (Settings -> Developer -> Edit Config):

{
  "mcpServers": {
    "wrapper": {
      "command": "npx",
      "args": ["-y", "@uvaresearch/wrapper-mcp"],
      "env": {
        "WRAPPER_PAT": "wrapper_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "WRAPPER_BASE_URL": "https://app.qmswrapper.com"
      }
    }
  }
}

Restart Claude Desktop after editing.

Cursor

Edit ~/.cursor/mcp.json:

{
  "mcpServers": {
    "wrapper": {
      "command": "npx",
      "args": ["-y", "@uvaresearch/wrapper-mcp"],
      "env": {
        "WRAPPER_PAT": "wrapper_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "WRAPPER_BASE_URL": "https://app.qmswrapper.com"
      }
    }
  }
}

Generic stdio MCP client

Spawn wrapper-mcp (or npx -y @uvaresearch/wrapper-mcp) as a subprocess with WRAPPER_PAT and WRAPPER_BASE_URL in the child environment, and speak JSON-RPC over its stdin/stdout. Other hosts (for example GitHub Copilot CLI) generally follow the same shape, but config file locations differ; see https://modelcontextprotocol.io/clients for the current list.

Step 3: verify

In Claude Code, run /mcp and confirm:

  • Server wrapper shows status connected.
  • Resources: 5 (plus 3 templates). Prompts: 5.
  • Tools: up to 28, but the server only registers the ones your PAT's scopes grant (see Tools). A task-only token sees the task-form.* + lookup tools; a token without storage scopes won't see any storage.* tool.

CLI smoke test (no PAT required):

npx -y @uvaresearch/wrapper-mcp --help
npx -y @uvaresearch/wrapper-mcp --version

If the server fails to start it writes a single-line reason to stderr and exits with code 64.

Environment variables

| Variable | Required | Default | Notes | | ------------------- | -------- | ---------------------- | ------------------------------------------------------------------------------------------------------ | | WRAPPER_PAT | yes | | Personal access token, wrapper_<32 alnum>. Must come from env, not CLI flag. | | WRAPPER_BASE_URL | yes | | Browser sign-in URL of your QMS Wrapper instance. HTTPS only; plain HTTP allowed only for loopback or *.local. | | WRAPPER_UPLOAD_MAX_BYTES | no | 52428800 (50 MB) | Per-file cap for task-form.attach / storage.file.upload. | | WRAPPER_CACHE_DIR | no | ~/.cache/wrapper-mcp | Created mode 0700. Holds the persistent per-token lookup cache (statuses/priorities/projects/users/members). | | DEBUG | no | unset | When set (any value), emits verbose HTTP and session logs to stderr. |

Tools, resources, prompts at a glance

Surfaces span three namespaces: task-form.* (task/form management), a small set of lookup tools, and storage.* (document storage). A task and a form are the same entity (forms are the custom tracker), so every task-form.* tool covers both. Future domains will use sibling namespaces without breaking existing tool names.

Scope-gated exposure: at startup the server reads the PAT's granted scopes and only registers the tools that at least one scope grants — the host never sees a tool the token can't use. The backend still enforces every scope with a 403, so this is a UX filter, not the security boundary; if scope discovery fails the server fails open (registers everything). The "Scopes" column below lists the any-of scopes that expose each tool.

Task/form tools (13)

| Name | Effect | Scopes / annotations | | -------------- | ------------------------------------------------ | -------------------------------------- | | task-form.list | List my tasks/forms, newest first. | tasks:read · read-only, idempotent | | task-form.number.get | Find which task a number refers to (the '#' number users paste). | tasks:read · read-only, idempotent | | task-form.get | Full task/form detail (journal, attachments, etc.). | tasks:read · read-only, idempotent | | task-form.update | Batched change incl. reassign (R1, R2 enforced). | tasks:write · write, non-destructive | | task-form.create | Create task/bug/form; preview + approval required (R9). | tasks:create · write, non-destructive | | task-form.templates.list | List the custom forms available to file (name + templateUuid). | tasks:read · read-only, idempotent | | task-form.template.get | Describe one form's fields (key, type, required, options, dependencies). | tasks:read · read-only, idempotent | | task-form.submission.get | Read back the answers stored on a task's form. | tasks:read · read-only, idempotent | | task-form.relate | Link this task/form to another. | tasks:write · write, non-destructive | | task-form.tag | Add/remove tags by name (idempotent). | tags:write · write, idempotent | | task-form.follow | Add/remove followers by user or group id (idempotent). | tasks:write · write, idempotent | | task-form.tags.list | List the org's existing tags (id + name). | tasks:read · read-only, idempotent | | task-form.attach | Upload one or more files in one multipart POST. | attachments:upload · write, non-destructive |

Two numbers per task. A task carries id — what every tool here takes — and issueId, which is the #84259 the web UI shows in lists, peek panels and relation labels. They are independent sequences both counting from 1, so one number can be valid as either: the id of one task and the issueId of another. A wrong reading therefore does not reliably 404; it can silently address a different task, and on a write that is a wrong entry in a QMS. When a number comes from a person, put it through task-form.number.get first: it checks both readings server-side in one request and returns either {ambiguous: false, id} or, when a number means two things, both candidates with their subjects for the user to choose between. It never guesses, and neither should the caller. Numbers taken from a previous tool result are already ids and need no lookup.

Followers. Followers (the UI calls them that; the API calls them watchers) are notified about a task without being its assignee. Set them when the task is created — task-form.create takes followerIds, so the people being looped in see it from its first notification — or change them later with task-form.follow, whose add / remove arrays are a delta: everyone already following stays unless named in remove, and re-adding or re-removing is a no-op. The merge happens server-side in one request; the underlying API call is a full replace, so computing the new list client-side would mean read-then-write with a window in which a concurrent change is silently reverted. Both paths take user or group ids from project.members and refuse anyone who is not a member of the task's project. task-form.get returns the current followers.

Writes from task-form.create / task-form.update are stamped with an aiContributions provenance payload (kind: "MCP_AGENT" per written field) so the backend can apply EU AI Act disclosure logic — every MCP-driven change is recorded as an autonomous AI-agent action.

Filing a form. custom is not one form — it is the family of your organization's custom forms, each with its own fields. So a form is created in three steps: task-form.templates.list to pick one (returns a templateUuid), task-form.template.get to read its fields, then task-form.create with that templateUuid plus a fields map keyed by each field's key. A value outside a choice field's declared options is refused before the write, and a key the form does not declare comes back as a warning on the result rather than blocking it. Whether a required field was answered is deliberately not checked here — that would mean guessing which keys the server fills from the task itself, and guessing wrong refuses valid records; the schema from task-form.template.get tells the model what to ask for instead. The keys the task's own arguments own (subject, assigneeId, statusId, priorityId, followers) are refused inside fields, since routing them there would skip the assignee/follower membership check and the status/priority name lookup. The /qms-file prompt walks this whole flow.

Lookup tools (3)

| Name | Effect | Scopes / annotations | | ------------------- | ------------------------------------------------ | -------------------------------------- | | project.members | List a project's assignable users + groups (use to resolve an assignee). | tasks:read · read-only, idempotent | | project.list | List / look up projects you belong to; q filters by name, status restricts (reaches closed/private ones). | tasks:read · read-only, idempotent | | organization.users | Search/list users across the organization. | tasks:read · read-only, idempotent |

Training tools (1)

| Name | Effect | Scopes / annotations | | --------------------------- | --------------------------------------------------------- | -------------------------------------- | | training.requirements.get | What training a person owes, with compliance counts. | training:read · read-only, idempotent |

Read-only by design. Completing, verifying and waiving training are Part 11 e-signatures performed by a person in the app — an agent must never hold a signing PIN, so no write tool exists here and none should be added. Omit userId to ask about yourself (the token identifies the caller). The response's summary counts every assignment, while assignments lists only what is still owed unless includeClosed is set — listScope says which you got. Beyond the scope, the backend also requires the Training Records package and refuses trial organizations, so a token holding training:read can still get a 403.

Storage tools (17)

Read tools need a storage:read scope; writes need storage:write, lock/unlock need storage:lock, trash needs storage:delete, and upload needs storage:upload.

| Name | Effect | Scopes / annotations | | --------------------------- | --------------------------------------------------------- | -------------------------------------- | | storage.folder.list | List a folder's contents, or search all storage. | storage:read · read-only, idempotent | | storage.file.get | Full metadata for one file or folder. | storage:read · read-only, idempotent | | storage.file.revisions | List a file's revisions, newest first. | storage:read · read-only, idempotent | | storage.file.download | Resolve a signed download URL (two-phase). | storage:read or attachments:download · read-only | | storage.approval.history | Combined approval + review history for a file. | storage:read · read-only, idempotent | | storage.tags.list | List the org's storage tags (id + name). | storage:read · read-only, idempotent | | storage.file.comment | Post a comment on a file (R10). | storage:write · write, non-destructive | | storage.file.rename | Rename a file (R10). | storage:write · write, non-destructive | | storage.file.move | Move a file to another folder (R10). | storage:write · write, non-destructive | | storage.file.copy | Copy a file to a destination folder (R10). | storage:write · write, non-destructive | | storage.file.tags.set | Replace all tags on a file (R10). | storage:write · write, idempotent | | storage.folder.create | Create a folder inside a parent (R10). | storage:write · write, non-destructive | | storage.file.restore | Restore a trashed file to its original location. | storage:write · write, non-destructive | | storage.file.lock | Lock (check out) a file to block concurrent edits. | storage:lock · write, idempotent | | storage.file.unlock | Release a lock without a new revision (undo checkout). | storage:lock · write, idempotent | | storage.file.trash | Soft-delete a file/folder (reversible via restore) — preview + approval (R9). | storage:delete · write, destructive | | storage.file.upload | Upload a file (two-phase preview + execute). | storage:upload · write, non-destructive |

Resources (5 concrete + 3 templates)

| URI | Returns | | -------------------------------------------------- | ------------------------------------------------ | | wrapper://lookups/statuses | All task/form statuses. | | wrapper://lookups/priorities | All task/form priorities. | | wrapper://lookups/trackers | All trackers (bug, task, etc.). | | wrapper://lookups/projects | Open projects the caller can file into, plus the server's default project id. | | wrapper://me | Identity of the PAT owner. | | wrapper://task-form/{id} | Full task/form detail. | | wrapper://task-form/{id}/journal | Journal entries for the task/form. | | wrapper://task-form/{id}/attachments/{fileId} | Signed download URL + metadata for one file. |

Task/form prompts (5)

| Slash command | Purpose | | --------------- | ------------------------------------------------------------- | | /qms-plate | Summarize my open tasks grouped by priority. | | /qms-summary | Read a task plus attachments; produce a user-language recap. | | /qms-start | Flip a task to in-progress in one atomic update. | | /qms-finish | Close out a task with status=resolved + comment in one call. | | /qms-file | Gather, preview, approve, and create a new task/form (R9). |

Security and threat model

This section reflects what the code in src/ actually enforces. If a claim is not backed by code, it is not made here.

What the client does

  • Opens one outbound TCP connection per request to WRAPPER_BASE_URL using Node's built-in fetch.
  • Sends Authorization: Bearer <PAT> plus a small set of X-Mcp-* headers (X-Mcp-Session-Id, X-Mcp-Tool-Name, optional X-Mcp-Request-Id) so the backend can audit each call.
  • Reads/writes only inside WRAPPER_CACHE_DIR (default ~/.cache/wrapper-mcp, created with mode 0700). It persists a lookup cache there (entities-<hash>.json, mode 0600): statuses, priorities, trackers, projects, users, and project members learned from your own lookups, so repeat lookups resolve without re-hitting the backend. The file name is keyed by a hash of the base URL + PAT so two tokens sharing a cache dir stay isolated; the PAT itself is never written. Delete the file any time to clear it.
  • For task-form.attach / storage.file.upload, reads file paths supplied by the user in the tool arguments and includes their full contents as multipart fields. Paths must be absolute; symlinks, non-regular files, and files over the size cap are refused.

What the client does NOT do

  • No telemetry. No analytics. No usage pings.
  • No auto-update. The binary you installed is the code that runs.
  • No remote config. All configuration is local env vars and tool args.
  • No outbound network calls beyond WRAPPER_BASE_URL. There is no fallback host and no DNS prefetch.
  • No filesystem access outside WRAPPER_CACHE_DIR and the explicit file paths passed to task-form.attach / storage.file.upload.

What the PAT grants and does not grant

A PAT carries the scopes the user ticked at creation time. With default scopes the bearer can read, write, and create tasks they would normally have access to in the web UI. It is bearer-only: anyone who holds the plaintext value can act as the user until the token is revoked. It does not grant filesystem, OS, or shell access on the QMS Wrapper host. The server scopes the bearer to the issuing user; it does not elevate.

Hardening baked in

  1. URL guard (src/security/urlGuard.ts): refuses WRAPPER_BASE_URL that is not https://, except for loopback hosts (localhost, 127.0.0.1, ::1) and *.local. Also rejects userinfo (user:pass@host) and any RFC1918 / link-local / cloud-metadata IP literal. Fails closed at process start before any HTTP call.
  2. Response scrubber (src/security/responseScrubber.ts): runs over every tool / resource / error payload before it leaves the process and replaces any wrapper_<32 alnum> substring with [REDACTED]. The check happens on the serialized JSON string, so exotic types cannot bypass. If a match ever fires it also emits a stderr warning so the regression is visible.
  3. Safe logger (src/security/safeLog.ts): writes only to stderr (stdout is reserved for JSON-RPC framing). Every line is scrubbed twice: once by the PAT regex and once by literal-replacing the current WRAPPER_PAT env value even if it does not match the regex shape.
  4. CLI flag refused: --pat, --token, --bearer, --auth, -p, -t, their =value forms, glued shorts (-pSECRET), case variants, and positional wrapper_<32 alnum> arguments all exit with code 64 before doing anything else. Rationale: command-line tokens land in shell history and ps.
  5. Minimal dependency tree: runtime deps are @modelcontextprotocol/sdk and zod. No HTTP client library, no logger framework, no arg parser. Smaller supply-chain attack surface.
  6. No telemetry, no auto-update, no remote config (see above).
  7. Upload path validation (src/uploadGuard.ts, shared by task-form.attach and storage.file.upload): requires an absolute path, rejects symlinks, refuses non-regular files, and caps file size at 50 MB by default (WRAPPER_UPLOAD_MAX_BYTES).
  8. Scope-gated tool exposure (src/index.ts): the server reads the PAT's granted scopes once at startup and skips registering any tool the token can't reach, so the host never lists a dead tool. This is a UX filter only — the backend enforces every scope with a 403 — so scope discovery failing fails open (all tools registered) rather than locking the user out.

Known limitations

  • Cookie jar deferred: the client is Bearer-only. If the backend ever starts requiring a session cookie alongside the PAT, this client will need a cookie store; today it sends no cookies.
  • task-form.attach reads each uploaded file fully into memory before posting. Very large attachments will be bounded by Node's heap.

What users should do

  • Issue PATs with the narrowest scope set that gets your work done.
  • Rotate PATs periodically; revoke on device loss or job change.
  • Prefer HTTPS endpoints. The URL guard will refuse anything else outside of loopback / *.local dev hosts.
  • Keep the PAT in the MCP host's env block (which most hosts read from a permissioned config file), not in shell rc files.

Troubleshooting

| Symptom | Likely cause / fix | | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | WRAPPER_PAT is not set | Env var missing or empty in the MCP host config. Restart the host after editing the config file. | | HTTP 401 from any tool | PAT expired or revoked. Mint a new one in Profile -> Access Tokens. | | HTTP 403 with insufficient_scope | PAT lacks a scope. For task-form.attach tick attachments:upload; for other tools, tick the matching scope on the PAT. | | HTTP 404 on every request | WRAPPER_BASE_URL points at the wrong host. Use the URL you would type into the browser to sign in (e.g. https://app.qmswrapper.com). | | WRAPPER_BASE_URL must use https:// | URL guard rejected a plaintext HTTP URL on a non-loopback host. Use https:// or move to a localhost / *.local dev URL.| | PAT must be supplied via the WRAPPER_PAT environment variable | You passed --pat=... on the command line. Move the token into the env block of your MCP host config. | | upload refused: symbolic links are not allowed / not a regular file | The upload path is a symlink or a non-regular file. Pass an absolute path to a real file. | | A tool you expected is missing from /mcp | The PAT lacks the scope that exposes it (the server hides tools the token can't use). Mint a PAT with the needed scope. Storage tools need storage:* scopes; task-form.attach needs attachments:upload. | | Empty tools list in Claude Code after /mcp | The server crashed at startup. Run wrapper-mcp --version in a terminal to confirm the binary works, then check stderr. | | npm install fails | Node 20+ is required. Check node --version. | | previewedHash mismatch on a storage write | You called storage.file.trash / storage.file.upload execute-phase with a stale hash. Re-run the preview call (omit previewedHash), then pass the hash it returns. | | task-form.update refused: assigneeId requires userExplicitlyRequested: true (R1) | The agent tried to reassign without explicit user instruction. Confirm with the user, then re-call with the flag set on task-form.update. |

License

MIT. See LICENSE.