ZW MCP
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@ZW MCPSend an envelope from template to john.doe@example.com"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 :8787What goes in .env
Variable | Where it comes from |
| You generate it: |
| DocuSign Settings -> Apps and Keys -> your app -> Integration Key. |
| Same page, the User ID GUID of the user to impersonate. Not the account ID. |
| Path to the RSA private key you generate on that app. Save it outside git ( |
| Optional -- auto-discovered from |
|
|
| Comma-separated product list. Drives the consent scopes and which tools register. |
Consent
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 scopeThat 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,maestroConnecting 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 MCPURL:
http://your-host.local:8787/mcp(orhttp://127.0.0.1:8787/mcpon 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/mcpLeave 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 |
| RFC 9728 | Points clients at the authorization server |
| RFC 8414 | Endpoint + capability discovery |
| RFC 7591 | Dynamic client registration |
| OAuth 2.1 | Approval page; PKCE required |
| OAuth 2.1 | Code exchange and refresh, with rotation |
Design decisions worth knowing:
Both auth paths work. The static
ZW_MCP_TOKENstill 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 entrypointRemote 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/mcpSetup, 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 8787Two things must be enabled in the Tailscale admin console first, or the funnel command hangs silently with no error:
HTTPS Certificates -- https://login.tailscale.com/admin/dns
The
funnelnode 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 # offTunnel 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.mcpDefault -- Tailscale Serve (private, tailnet-only HTTPS):
tailscale serve --bg 8787
tailscale serve status # shows your https://<machine>.<tailnet>.ts.net URLOnly 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 8787That 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-launchdThe 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 .statusAfter 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.adminTo stop or remove:
launchctl bootout gui/$(id -u)/com.zw.mcp
launchctl bootout gui/$(id -u)/com.zw.mcp.adminAll 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 |
|
FileVault off (or unlocked) | An encrypted disk must be unlocked before any login can happen, automatic or not |
|
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-bootThe 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 |
| ZW MCP |
8443 |
| (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:8788Six 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 smokewith 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 |
|
| Listen port |
|
| Bind address; |
| (unset) | Enables HTTP Basic auth |
|
| Basic auth username |
| (unset) | Deliberately skip auth on a non-loopback bind |
|
| 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 | jqReports 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 |
|
|
| Compile to |
| Run the built server |
| stdio transport, for MCP Inspector / local debugging |
| Print the one-time DocuSign consent URL |
| One cheap read per enabled product, ✅/❌ table |
| Probe each OAuth scope individually to find one the account has not granted |
| Admin console on :8788, loopback only |
| Admin console bound to |
|
|
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.mdLicense
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.
This server cannot be deployed
Maintenance
Related MCP Connectors
- OneOAuthai.withone
Search, document and execute authenticated API calls across 700+ apps via one MCP server
Send transactional email and manage domains, audiences, and broadcasts from any MCP client.
A managed runtime for custom API integrations. Manage lines, endpoints, keys, logs and DLQ via MCP.
Related MCP Servers
AlicenseBqualityCmaintenanceEnables developers to interact with SignNow API for document signing workflows, providing documentation access, code examples, and authentication help.110MIT- FlicenseDqualityDmaintenanceEnables to interact with Nylas API for email, calendar, and contacts via MCP, providing documentation, code samples, and code generation tools.3-
- AlicenseAqualityDmaintenanceProvides DocuSign eSignature operations via JWT Grant for headless AI agents, enabling envelope management, document signing, and template access through MCP tools.9MIT
- FlicenseNot gradedqualityDmaintenanceProvides comprehensive access to the Constant Contact API v3 for email marketing, campaign management, contact management, analytics, and automation through MCP tools.2-