Skip to main content
Glama
zach-woodward

ZW MCP

ZW MCP

A universal Docusign MCP server. One always-on service on the Mac mini exposing every DocuSign product API -- eSignature, Navigator, CLM, Maestro, Web Forms, Rooms, Click, Admin, Monitor, Notary, Connected Fields, Workspaces, Trust Records -- to Claude Desktop, claude.ai, Claude Code, Claude Cowork, and the skinned demo UIs that will front it later.

See docs/ARCHITECTURE.md for diagrams and the design rationale, and specs/BASE_PATHS.md for the verified base URI and scope tables.

Design in one paragraph

DocuSign has well over a thousand endpoints; one MCP tool each would drown every client. So tools come in two tiers: curated tools (esign_list_envelopes, esign_create_envelope_from_template, ...) hand-written for what demos actually do, with trimmed responses and model-facing descriptions; and a <product>_raw_request escape hatch per API that reaches every remaining endpoint with auth, base URI and {accountId} substitution handled. Coverage is complete from day one.

Related MCP server: Nylas API MCP Server

Setup

npm install
cp .env.example .env      # then fill it in -- see below
npm run consent           # prints the one-time consent URL; open it, click Accept
npm run smoke             # ✅/❌ table, one cheap call per enabled product
npm run dev               # tsx watch on :8787

What goes in .env

Variable

Where it comes from

ZW_MCP_TOKEN

You generate it: openssl rand -hex 32. Every MCP client sends it as a bearer token.

DS_INTEGRATION_KEY

DocuSign Settings -> Apps and Keys -> your app -> Integration Key.

DS_USER_ID

Same page, the User ID GUID of the user to impersonate. Not the account ID.

DS_RSA_PRIVATE_KEY_PATH

Path to the RSA private key you generate on that app. Save it outside git (keys/ and *.pem are gitignored).

DS_ACCOUNT_ID

Optional -- auto-discovered from /oauth/userinfo when blank.

DS_ENVIRONMENT

demo (default) or prod. Flips every base URI at once.

DS_PRODUCTS

Comma-separated product list. Drives the consent scopes and which tools register.

JWT Grant impersonation only works after the impersonated user has consented once per integration key. npm run consent prints the URL; open it, sign in as that user, click Accept. The server also detects consent_required at startup and prints the URL again.

A scope the account is not entitled to fails the entire consent grant, and DocuSign will record a consent that silently omits it -- so "I clicked Accept" is not proof a scope works. When a product returns consent_required, run:

npm run scopecheck        # probes every scope individually, ✅/❌ per scope

That pinpoints the offending scope instead of guessing at a whole product's set. Read it in the negative direction only: DocuSign silently ignores unknown scopes, so a ✅ does not prove a scope is real -- but consent_required does prove it is recognised and simply not yet granted. Then drop it (or the product) from DS_PRODUCTS and re-run consent. This is not hypothetical: Navigator's documented-best-practice models_read scope is not grantable on the a live demo account, and including it broke every Navigator call until scopecheck isolated it.

To consent for a later phase's scopes in the same click:

npm run consent -- --products esign,navigator,maestro

Connecting clients

All three need an HTTPS URL from outside this machine; see Remote access below. Locally, http://127.0.0.1:8787/mcp works.

Claude Code

On this machine:

claude mcp add zw-mcp \
  --transport http \
  --scope user \
  http://127.0.0.1:8787/mcp \
  --header "Authorization: Bearer <ZW_MCP_TOKEN>"

From another machine on the LAN, swap in the host's address:

claude mcp add zw-mcp \
  --transport http \
  --scope user \
  http://your-host.local:8787/mcp \
  --header "Authorization: Bearer <ZW_MCP_TOKEN>"

Claude Desktop

Settings -> Connectors -> Add custom connector:

  • Name: ZW MCP

  • URL: http://your-host.local:8787/mcp (or http://127.0.0.1:8787/mcp on the host itself)

  • Header: Authorization: Bearer <ZW_MCP_TOKEN>

Or edit ~/Library/Application Support/Claude/claude_desktop_config.json directly:

{
  "mcpServers": {
    "zw-mcp": {
      "type": "http",
      "url": "http://your-host.local:8787/mcp",
      "headers": { "Authorization": "Bearer <ZW_MCP_TOKEN>" }
    }
  }
}

claude.ai and Claude Cowork

Settings -> Connectors -> Add custom connector:

  • URL: https://your-host.your-tailnet.ts.net/mcp

  • Leave OAuth client ID and client secret blank

Claude custom connectors have no field for a static bearer header. They speak the MCP OAuth flow instead: on a 401 they read the WWW-Authenticate header, fetch the protected-resource metadata, discover the authorization server, register themselves dynamically, and run authorization-code + PKCE. That is why the client ID and secret are optional -- dynamic registration fills them in.

ZW MCP implements that whole surface (see OAuth below). When you add the connector you will be sent to an approval page; paste ZW_MCP_TOKEN there to approve, and the connector receives its own OAuth token.

OAuth

src/auth/oauth.ts is a minimal OAuth 2.1 authorization server colocated with the resource server, which the MCP spec explicitly permits.

Endpoint

Spec

Purpose

/.well-known/oauth-protected-resource

RFC 9728

Points clients at the authorization server

/.well-known/oauth-authorization-server

RFC 8414

Endpoint + capability discovery

/register

RFC 7591

Dynamic client registration

/authorize

OAuth 2.1

Approval page; PKCE required

/token

OAuth 2.1

Code exchange and refresh, with rotation

Design decisions worth knowing:

  • Both auth paths work. The static ZW_MCP_TOKEN still authenticates Claude Code, Claude Desktop and Agent SDK apps; OAuth tokens serve the connectors. Dropping the static path to serve the connectors would have broken the rest.

  • The resource owner is authenticated by knowledge of ZW_MCP_TOKEN. This is a single-operator server; a second credential store would add surface without adding a boundary.

  • Tokens are opaque and stored server-side, so audience validation is a lookup rather than JWT claim parsing, and there is no signing key to manage.

  • Audience binding is enforced (RFC 8707): a token issued for another resource is rejected, which the spec requires.

  • State persists to .oauth/state.json (gitignored, mode 0600) so a restart does not silently log every connector out.

Verified end to end against the public URL: registration, PKCE challenge, approval, code exchange, MCP call with the issued token, refresh with rotation, and rejection of both a bad PKCE verifier and a forged token.

Both connect from Anthropic's servers, not from your browser or machine, so they need a public HTTPS URL. A LAN IP, *.local name, localhost, or a tailnet-only Tailscale Serve address will not work for them -- only Funnel (or another public tunnel) does. See Remote access.

Verified reachable from outside the tailnet: GET /health returns 200 from Anthropic's infrastructure, POST /mcp without a bearer returns 401, and with one returns all 84 tools. Port 8788 (the admin console) is deliberately NOT funnelled.

Your own app (Claude Agent SDK)

The Agent SDK runs in your process, so it reaches ZW MCP over localhost or the LAN with no tunnel:

mcpServers: {
  'zw-mcp': {
    type: 'http',
    url: 'http://127.0.0.1:8787/mcp',
    headers: { Authorization: `Bearer ${process.env.ZW_MCP_TOKEN}` },
  },
},
allowedTools: ['mcp__zw-mcp__*'],

A runnable starter is in examples/agent-sdk/. This is the pattern the skinned demo UIs use -- they hold no Docusign logic, just a URL and a token.

MCP Inspector (debugging)

npx @modelcontextprotocol/inspector          # then point it at /mcp with the bearer header
npx @modelcontextprotocol/inspector npx tsx src/stdio.ts   # or drive the stdio entrypoint

Remote access

Which clients can reach ZW MCP depends on where the client actually runs:

Client

Runs where

LAN IP works?

Claude Code

your machine

Claude Desktop

your machine

claude.ai custom connector

Anthropic's servers

❌ needs public HTTPS

Claude Cowork

Anthropic's servers

❌ needs public HTTPS

This is the trap. Tailscale Serve publishes to your tailnet only, so it works for Claude Desktop and Claude Code but NOT for claude.ai or Cowork -- those connect from Anthropic's infrastructure, which is not on your tailnet and cannot resolve a *.ts.net name. For those you need genuinely public HTTPS: Tailscale Funnel, Cloudflare Tunnel, or similar.

LAN access (no tunnel)

HOST=0.0.0.0 in .env makes the server reachable from other machines on your network at http://<lan-ip>:8787/mcp. Every request still requires the ZW_MCP_TOKEN bearer, which is exactly what that token is for -- an unauthenticated request gets a 401.

Set HOST=127.0.0.1 to restrict it to this machine again.

Public HTTPS (for claude.ai / Cowork) -- Tailscale Funnel

Do not raw port-forward. This host uses Tailscale Funnel, which terminates TLS and gives a real public hostname:

https://your-host.your-tailnet.ts.net/mcp

Setup, for reference or rebuilding:

brew install tailscale        # CLI formula; the GUI cask needs an interactive sudo password

# Userspace mode needs no root. Funnel works fine this way.
tailscaled --tun=userspace-networking \
  --socket=$HOME/.tailscale/tailscaled.sock \
  --statedir=$HOME/.tailscale/state &

tailscale --socket=$HOME/.tailscale/tailscaled.sock up --hostname=your-host
tailscale --socket=$HOME/.tailscale/tailscaled.sock funnel --bg 8787

Two things must be enabled in the Tailscale admin console first, or the funnel command hangs silently with no error:

  1. HTTPS Certificates -- https://login.tailscale.com/admin/dns

  2. The funnel node attribute -- https://login.tailscale.com/admin/acls:

"nodeAttrs": [
  {"target": ["autogroup:member"], "attr": ["funnel"]}
]

Check both landed with:

tailscale --socket=$HOME/.tailscale/tailscaled.sock status --json \
  | python3 -c "import sys,json; d=json.load(sys.stdin); \
      print('funnel:', any('funnel' in str(c).lower() for c in (d['Self'].get('CapMap') or {}))); \
      print('certs :', d.get('CertDomains'))"

tailscaled runs under launchd as com.zw.tailscaled, so it comes back after a reboot. The Funnel configuration itself lives in the tailscaled state directory and is restored when the daemon restarts -- the agent does not re-run tailscale funnel.

Toggle Funnel from the admin console's Network tab, or from the CLI:

tailscale --socket=$HOME/.tailscale/tailscaled.sock funnel --bg 8787      # on
tailscale --socket=$HOME/.tailscale/tailscaled.sock funnel --https=443 off # off

Tunnel 8787 only. Never tunnel 8788: the admin console has no password and can send and void envelopes.

Behind a public tunnel, ZW_MCP_TOKEN is the only thing between the internet and your Docusign account. Rotate it before going public:

openssl rand -hex 32      # put in .env, then:
launchctl kickstart -k gui/$(id -u)/com.zw.mcp

Default -- Tailscale Serve (private, tailnet-only HTTPS):

tailscale serve --bg 8787
tailscale serve status          # shows your https://<machine>.<tailnet>.ts.net URL

Only devices on your tailnet can reach that URL, and it already has a valid HTTPS certificate -- which is what claude.ai's custom connectors require.

Only if a genuinely public URL is ever needed -- Tailscale Funnel:

tailscale funnel --bg 8787

That exposes the endpoint to the public internet. The bearer token becomes the only thing standing in front of your DocuSign account, so rotate ZW_MCP_TOKEN and prefer Serve.

Always-on with launchd

Three agents: the MCP server, the admin console, and the Tailscale daemon that provides public access. They are separate so any one can be restarted alone.

npm run build
npm run install-launchd

The plists are templated with __HOME__ so the repo carries no machine-specific paths; the installer substitutes your home directory and bootstraps each agent. It skips the Tailscale agent if tailscaled is not installed.

Verify:

launchctl print gui/$(id -u)/com.zw.mcp | grep -E 'state|pid'
launchctl print gui/$(id -u)/com.zw.mcp.admin | grep -E 'state|pid'
curl -s http://127.0.0.1:8787/health | jq .status

After a rebuild (npm run build writes dist/, which is what launchd runs):

npm run build
launchctl kickstart -k gui/$(id -u)/com.zw.mcp
launchctl kickstart -k gui/$(id -u)/com.zw.mcp.admin

To stop or remove:

launchctl bootout gui/$(id -u)/com.zw.mcp
launchctl bootout gui/$(id -u)/com.zw.mcp.admin

All three have KeepAlive (restarted if they die) and ThrottleInterval 10s, so a bad .env backs off instead of hot-looping.

Surviving a reboot

RunAtLoad is only half the story. These are LaunchAgents, which live in the gui/<uid> domain and load when a GUI session starts -- not at boot. On a headless always-on machine that distinction decides whether anything comes back:

Requirement

Why it matters

Check

Automatic login enabled

Without it the machine boots to a login window and every agent stays stopped, with no error anywhere

defaults read /Library/Preferences/com.apple.loginwindow autoLoginUser

FileVault off (or unlocked)

An encrypted disk must be unlocked before any login can happen, automatic or not

fdesetup status

Both currently hold on this machine. If you ever enable FileVault or turn off automatic login, ZW MCP silently stops surviving reboots -- so re-check after any change to either.

Verify the whole stack after a restart:

npm run verify-boot

The public checks resolve over DNS-over-HTTPS. Some home routers return NXDOMAIN for tailnet hostnames, which makes a perfectly healthy public endpoint look dead from inside your own network -- curl fails at DNS before the request ever leaves the building. If that happens, the script says so explicitly rather than reporting an outage. To fix it at the source, point the machine's resolver at 1.1.1.1 or 8.8.8.8 instead of the router.

It checks all three agents, the local and public endpoints, that an unauthenticated request is still rejected, the Tailscale Funnel mappings, and both reboot prerequisites above.

It also reports every other com.zw.* agent on the machine, because they share one fate: all of them are LaunchAgents in the same GUI domain, so turning off automatic login stops all of them coming back, not just this project's.

Funnel port budget

Tailscale Funnel permits exactly three ports: 443, 8443, 10000. Current use on this machine:

Public port

Proxies to

Service

443

127.0.0.1:8787

ZW MCP

8443

127.0.0.1:8790

(separate project)

10000

free

A fourth public service needs path-based serve on 443 (tailscale funnel --bg --set-path=/name <port>) rather than another port.

This was validated by tearing all three agents down and letting launchd cold-start them: Tailscale reconnected, Funnel restored itself from the state directory, OAuth metadata served, and issued grants survived -- no manual step required.

If you want to eliminate the login dependency entirely, the alternative is converting these to LaunchDaemons in /Library/LaunchDaemons, which start at boot with no session. That needs sudo, and the daemons would run as root rather than as your user, so .env, ~/.tailscale/state and the Homebrew paths would all need revisiting. Given automatic login is already on, the agents are the simpler correct answer here.

The admin plist sets ADMIN_BIND=0.0.0.0 and ADMIN_ALLOW_INSECURE=1, making the console reachable at http://your-host.local:8788 with no password. That is a deliberate choice for a demo account -- see the warning under Admin console.

Logs: logs/zw-mcp.log (structured, redacted), logs/launchd.{out,err}.log, and logs/launchd.admin.{out,err}.log.

Admin console

A local operations console for seeing what exists, exercising it, and tracking what is left:

npm run dev      # terminal 1 -- ZW MCP on :8787
npm run admin    # terminal 2 -- console on :8788
open http://127.0.0.1:8788

Six tabs:

  • Status -- server health, token expiry, granted scopes, resolved account and organization, plus a per-product card. "Probe every product" fires one cheap read-only tool per API and turns each card green or red, which is npm run smoke with a UI.

  • Tools -- every registered tool grouped by product, filterable, each showing its model-facing description and full JSON input schema.

  • Run -- pick any tool, edit its arguments as JSON (pre-filled from the schema defaults), execute it, and read the result with timing.

  • Activity -- the structured log made legible: Docusign API calls, inbound HTTP, auth/OAuth events and errors, filterable by kind and text, with optional auto-refresh. Also lists OAuth grants and lets you revoke a client. Revocation takes effect in the MCP server immediately -- it reloads the grant state when the file changes, so no restart is needed.

  • Network -- Tailscale state and a switch to turn public Funnel access on or off. Shows whether the tailnet actually allows Funnel and whether HTTPS certs are issued, because without those the underlying command hangs silently instead of erroring.

  • Next -- phase status, open items with the action each needs, and the standing gotchas worth remembering.

The Funnel switch lives here and not in the MCP tool surface on purpose. /mcp is published to the public internet while Funnel is on, so an MCP tool that toggled Funnel would let anyone holding the bearer token re-open the tunnel after it was closed, or close it and cut off every other client. The console is LAN-only and never funnelled, which makes it the right place to control the machine's own network exposure.

It is a separate app on purpose. ZW MCP itself stays UI-free (see docs/ARCHITECTURE.md §7), and the console drives it over exactly the same bearer-authed /mcp endpoint that Claude Desktop or claude.ai uses -- so anything the console can do, a real MCP client can do. The bearer token stays in the console's server process and never reaches the browser.

Reaching the console from other machines

By default the console binds 127.0.0.1. To open it to your LAN:

npm run admin:lan          # binds 0.0.0.0, no password (ADMIN_ALLOW_INSECURE=1)

Then browse to http://<mac-mini-lan-ip>:8788 or http://your-host.local:8788.

Understand what that exposes. The console holds the ZW MCP bearer token server-side and its Run tab can send and void real envelopes, so reaching the console is reaching DocuSign. On a trusted LAN with a demo account that is a reasonable trade; with a production account it is not.

The server fails closed: binding anywhere but loopback without a password refuses to start unless you set ADMIN_ALLOW_INSECURE=1, so it can never be exposed by accident. To add a password instead:

ADMIN_BIND=0.0.0.0 ADMIN_PASSWORD=$(openssl rand -hex 24) npm run admin
# browser prompts for HTTP Basic; default user is "zw" (override with ADMIN_USER)

Variable

Default

Purpose

ADMIN_PORT

8788

Listen port

ADMIN_BIND

127.0.0.1

Bind address; 0.0.0.0 for LAN

ADMIN_PASSWORD

(unset)

Enables HTTP Basic auth

ADMIN_USER

zw

Basic auth username

ADMIN_ALLOW_INSECURE

(unset)

Deliberately skip auth on a non-loopback bind

ZW_MCP_URL

http://$HOST:$PORT/mcp

Which ZW MCP to drive

Basic auth over plain HTTP base64-encodes credentials rather than encrypting them. For anything leaving your network, put Tailscale Serve in front (see Remote access) rather than relying on that.

MCP resources and prompts

Beyond tools, the server exposes:

  • docusign://overview -- the product map: every API, its tool prefix, whether it is enabled, and how the two tiers work. Start here.

  • docusign://apis/<product> -- a per-API cheat sheet: base URI, path shape, scopes, curated tool list, and the raw hatch.

  • demo_context (prompt) -- loads the live demo board at the start of a session: account and organization, enabled APIs, most-used eSignature templates, envelope count for the last 30 days, a Navigator agreement-type breakdown, active Maestro workflows, and CLM reachability. Every lookup is best-effort and degrades to a note, so an unentitled product never fails the prompt.

Health

curl -s http://127.0.0.1:8787/health | jq

Reports uptime, environment, access-token expiry and granted scopes, the resolved account, and the base URI of every enabled product. It is unauthenticated by design so a monitor can poll it; it never returns the token itself.

Scripts

Command

What it does

npm run dev

tsx watch on the HTTP server

npm run build

Compile to dist/

npm start

Run the built server

npm run stdio

stdio transport, for MCP Inspector / local debugging

npm run consent

Print the one-time DocuSign consent URL

npm run smoke

One cheap read per enabled product, ✅/❌ table

npm run scopecheck

Probe each OAuth scope individually to find one the account has not granted

npm run admin

Admin console on :8788, loopback only

npm run admin:lan

Admin console bound to 0.0.0.0, no password

npm run typecheck

tsc --noEmit

Repository layout

src/
  index.ts          HTTP entrypoint: express, bearer auth, /mcp, /health
  stdio.ts          stdio entrypoint (debug)
  server.ts         MCP server assembly
  auth/jwt.ts       JWT Grant, token cache, userinfo discovery
  auth/scopes.ts    per-product OAuth scope sets
  clients/          products.ts (base URI registry) + base.ts (signed requests)
  tools/            per-product curated tools + raw.ts escape-hatch factory
  resources/        docusign://apis/* cheat sheets + demo_context prompt
  lib/              config, logging, response shaping and download handling
admin/              local ops console (separate app; ZW MCP stays UI-free)
examples/agent-sdk/ minimal Claude Agent SDK app -- the pattern demo skins use
specs/              verified base-path/scope tables, vendored OpenAPI specs
scripts/            consent.ts, smoke.ts
launchd/            com.zw.mcp.plist
docs/ARCHITECTURE.md

License

Source-available, not open source. See LICENSE: you may read and privately run this code; redistribution and derivative works need permission.

It is published this way deliberately. The repository is a reference implementation and a record of how the integration was built -- including the places where vendor documentation disagreed with vendor behaviour. Granting reuse rights would be a claim about ownership that is not mine alone to make.

Nothing here contains credentials. .env, private keys, and OAuth state are gitignored and verified absent from the entire commit history.

Related MCP Connectors

Related MCP Servers