AI assistant connector (MCP) Module Included
What it does — and what it does not
MCP (Model Context Protocol) is a standard way for an AI assistant to call an external tool. Vaks PM exposes an MCP server so an assistant can query and update the workspace on a signed-in user's behalf.
- The assistant acts as a person, not as itself. The user signs in through their normal login — local password or your SSO. The assistant then operates strictly within that user's permissions.
- Read by default, write only if you allow it. Out of the box the assistant can observe and suggest. Write tools appear only when an administrator turns them on.
- Finance is separately gated. Financial data stays invisible to assistants unless you deliberately expose it, even to a user who can see it in the browser.
- Every action is attributed. An MCP action is recorded as an agent action in the audit log, distinct from the same person clicking in the browser, and carries the reason the assistant gave and which assistant it was.
- Destructive actions ask twice. Before deleting something or notifying stakeholders, the assistant must show you what it is about to do and repeat the call to actually do it — see confirmation & rate limits.
- It is included, free. No license required — just enable it per organization (opt-in,
mcp.enabled).
The permissions model
This is the reassuring part, and worth stating plainly to anyone nervous about connecting an AI to their project data:
Concretely, three switches an administrator controls decide the ceiling:
| Toggle | Off (default) | On |
|---|---|---|
| Enable the AI connector | No one can connect; consent is refused. | Users can connect and sign in. |
| Allow AI write actions | Read-only: the assistant observes and suggests. | Write tools appear, bounded by each user's permissions. |
| Expose finance to AI | Budgets, margins and rates are invisible to assistants. | Finance is readable, still only for users who already have finance access. |
Prerequisites
| Side | What you need |
|---|---|
| Licence | None. The connector is part of the free core; just enable it (opt-in, mcp.enabled). |
| Vaks PM | org:manage to enable and configure it. Individual users need only their normal account to connect. |
| The assistant | An MCP-capable client. Claude and ChatGPT self-register; Copilot Studio and Power Platform need a pre-registered client — see the Copilot Studio guide. Desktop and CLI clients that redirect to a local port (e.g. a VS Code extension) self-register too and are recognised as such, so the varying port they pick each launch is accepted automatically. |
| Reachability | The assistant is a cloud service, so it must be able to reach your MCP endpoint. For a private instance this needs deliberate exposure — see reaching it from outside. |
Step 1 — Enable and scope it
Open Admin → Integrations → AI connector (MCP).
- Turn on Enable the AI connector (MCP).
- Decide on Allow AI write actions. Start read-only. You can turn writes on later once you have seen how assistants are used.
- Decide on Expose finance to AI. Leave off unless you specifically want assistants reading financial figures.
- Copy the MCP server URL shown in the first card — this is what users paste into their assistant.
Step 2 — Connect an assistant
A user adds Vaks PM as a custom connector in their assistant and pastes the MCP server URL. The assistant discovers how to authenticate on its own and opens a sign-in flow:
- The assistant sends the user to Vaks PM to sign in (local or SSO).
- The user sees a consent screen stating exactly what the assistant will be able to do.
- On approval, the assistant receives a short-lived access token and can start working.
The connection is standard OAuth with PKCE; access tokens last one hour and refresh automatically. How much setup this takes depends on the assistant — each has a step-by-step guide:
| Assistant | What it takes | Guide |
|---|---|---|
| Claude | Self-registers. Paste the URL, sign in. Shortest. | Connect Claude → |
| ChatGPT | Self-registers, via connectors / developer mode (beta UI). | Connect ChatGPT → |
| Copilot Studio | Pre-registered client + per-user auth + Teams distribution. Longest. | Connect Copilot Studio → |
Any local or open-source MCP client
Beyond the three managed assistants, MCP is an open standard: local and open-source clients — Cline, Continue, LM Studio, Open WebUI, Goose, and IDE clients such as VS Code, Cursor, Windsurf or Zed — can connect to the same endpoint. Support varies, so there are two paths:
- OAuth (self-register) — clients that implement remote OAuth discover and sign in exactly like Claude: paste the MCP URL, approve consent in the browser. Nothing to pre-register.
- Paste a token — clients without remote OAuth (or set up for a local server) take a personal API token scoped to
mcp:accessthat the user creates in their account settings and pastes into the connector. Same gating, same per-user permissions, no browser sign-in — see Option C.
Pre-built workflows (MCP prompts)
Vaks ships a set of pre-built workflows — one-click templates that drive the assistant through a whole task (write a status report, staff a project, produce a portfolio risk radar). Technically these are MCP prompts: a pre-written instruction injected into the conversation, nothing more.
The library (write workflows appear only when Allow AI write actions is on):
| Workflow | What it does | Type |
|---|---|---|
| Portfolio · Risk radar | Reviews every project and produces a prioritised list of risks. | read |
| Portfolio · Deliverables awaiting review | Prioritises what is waiting for my review and prepares it. | read |
| Project · Status report | Writes a shareable progress update for a project. | read |
| Team · Find work for someone | Projects and tasks matching someone's skills and real availability. | read |
| Finance · Budget audit | Budget, spend, forecast and variance — with corrective levers. | read |
| Project · Start from a brief | Turns a brief into a project: tasks, dependencies, staffing. | write |
| Project · Close out | Close-out checklist (tasks, deliverables, skills) then mark completed. | write |
| Project · Announce a change | Drafts and sends a project announcement (PM / sponsors / members / watchers). | write |
| Team · Staff a project | Proposes an optimal assignment (skills + real availability). | write |
| Team · Onboard someone | Team assignment, declared skills and a first mission. | write |
| Time · Timesheet catch-up | Spots the gaps in my week and proposes the missing time entries. | write |
The consent screen
Every connection passes through a consent screen so the user sees, and approves, exactly what they are granting. It shows your organization's name, the account they are signed in as, and a bulleted list of what the assistant will be able to do — view projects and tasks, view workload and reports, optionally view finance, and, when writes are enabled, an emphasised line about creating and modifying on their behalf. It ends with the reassurance that the assistant cannot exceed the user's own permissions.
If the connector is disabled at the moment of consent, the screen says so and offers no approve button — the connection cannot be completed.
Managing connections
Two views let you see and cut connections:
- Administrators: Admin → Security → Active connections → AI connections lists every user's connected assistant — who, which client, when last used — with a Disconnect button. Disconnecting is immediate: the short-lived token is revoked, not left to expire.
- Each user: their own Security settings list their AI connections with the same self-service disconnect.
Suspending or deleting a user also cuts their connections automatically.
Confirmation & rate limits
Before a destructive action — deleting a task, a client, a time entry, removing someone from a team, or notifying stakeholders — the assistant is stopped once. It gets back a description of exactly what the action would do, plus a one-time token, and has to show that to you and repeat the call with the token to actually carry it out. It is a genuine round trip bound to that specific action, not a box the assistant can tick on its own: the token expires after ten minutes and cannot be reused for anything else.
An administrator sets the policy under Admin → Integrations → AI connector (MCP) → Confirmation guard:
| Policy | Effect |
|---|---|
| Off | No confirmation is asked for any action. |
| Destructive actions (default) | Deletions and outward-facing actions (like notifying stakeholders) require confirmation; ordinary edits do not. |
| Every write action | Any change, including a simple edit, requires confirmation. |
The same section also has per-tool rate limits: an optional cap on how many times a specific tool may be called per minute, independent of the confirmation guard — a safety net against a runaway loop that would otherwise stay invisible until it shows up elsewhere. A live counter next to it shows what each tool is being called for right now.
Reaching it from outside
Cloud assistants must reach your MCP endpoint over the internet. How you expose it is a deployment choice, and it changes the URL — which is the single most common point of confusion.
example.com is a stand-in for your own domain — the one your instance is already served on. Everywhere you see example.com, read your own; everywhere you see demo, read one of your own tenant slugs. Vaks PM is self-hosted: there is no shared address, and nothing resolves to us.
Three topologies, each walked through step by step below:
- Assistants on the internet, instance private — an operator publishes MCP through an isolated gateway that holds no secrets and has no database access. This is the recommended hardened layout for exposing an internal instance. See Option A.
- Assistants and instance on the same network — no separate gateway needed; the endpoint is served on the tenant address and the admin-panel URL is correct as shown. See Option B.
- No OAuth flow at all — a user creates a personal API token scoped to MCP access in their account settings and pastes that into the connector. Simplest for a single power user; no browser sign-in. See Option C.
api/web/worker — running two replicas with a health check, so a rolling update never drops a client's request mid-call. If your instance was deployed or upgraded through the normal process, it is already running: check with docker service ls | grep mcp. What follows is for whoever operates the servers — not the tenant administrator — and is really about three other cases: Kubernetes, where MCP is not yet templated by the Helm chart and still needs a manual step; the hardened, fully isolated DMZ alternative for exposing a private instance; and understanding what is running, for troubleshooting.
How the MCP server relates to an existing instance
The MCP container is deliberately thin: it holds no secrets, talks to no database, and enforces nothing itself. It receives the AI client's token and relays it to your instance's internal API, which validates everything — PKCE, the consent screen, licence gating, RBAC, rate limiting. In other words you add a front door; you neither move nor duplicate any data.
| Point | Detail |
|---|---|
| Bundled by default (Swarm) | The vaks-pm/mcp:latest image ships in the standard stack next to api/web/worker — two replicas, spread across nodes, health-checked. The fully isolated DMZ topology (Option A) remains available as a separate docker compose deployment for operators who want the gateway entirely outside the Swarm overlay. |
| No secrets on the box | It stores nothing sensitive. All security (PKCE, consent, licence, RBAC, rate-limit) is enforced by the internal API, never by MCP. |
| One outbound flow | From the box to the internal API on :443, over a bounded set of paths (Traefik allow-list: local /mcp, forwarded auth surface). Everything else in the app stays unreachable through this door. |
| Host naming | A tenant is reached at <slug>.mcp.<domain>. A single wildcard certificate *.mcp.<domain> covers every tenant: the mcp label is stripped to reconstruct the tenant Host (<slug>.<domain>) passed to the API. |
| Application prerequisite | Independent of deployment: the tenant must have enabled the connector (mcp.enabled, step 1). Otherwise the API issues no token (403), whatever the topology. No license required — the connector is included. |
| Authorization-server mode | VAKS_AUTH_SERVER_MODE=self in a DMZ (the AS advertised to AI clients = the box, which forwards auth inward); =tenant internally (the AS = the tenant address, reached directly). |
Get the MCP image onto this host
Only needed for the isolated DMZ gateway (Option A) or a hand-rolled stack — a standard Swarm install already builds and ships this image on every node, alongside api/web/worker.
The simplest path is to build the image in place, from a copy of the repository. The MCP Dockerfile expects the repository root as its build context. No registry is required.
# On the MCP host: get a copy of the repository, then build from its root
git clone <repo-url> vakspm && cd vakspm
sudo docker build -t vaks-pm/mcp:latest -f mcp/Dockerfile .
sudo docker image ls vaks-pm/mcp # check: the image is present
docker save -o mcp.tar vaks-pm/mcp:latest, copy the mcp.tar file and the infra/dmz/ folder onto this host, then load the image locally:
# On the MCP host, after copying mcp.tar here
sudo docker load -i mcp.tar
Option A — Isolated DMZ gateway hardened
The recommended layout for exposing a private instance to cloud assistants. A DMZ box, which does not join the internal Swarm overlay, publishes /mcp to the internet and forwards the auth surface (OAuth + login + consent) to the on-premise API. The internal API is never reachable directly from the internet.
INTERNAL_EDGE_URL you give it. That works identically in front of a Kubernetes install: point it at the cluster's Ingress instead of the internal Traefik, and the rest of this section is unchanged. See Kubernetes deployment for the Ingress side.
/mcp itself and forwards only the authentication surface (OAuth, login, consent, discovery) to the internal API — the single outbound flow the firewall allows (DMZ → internal :443 only, nothing else). The internal cluster stays unreachable directly from the internet.Prerequisites, supplied by the operator:
| Item | Detail |
|---|---|
| DMZ host | Docker + Docker Compose. Outside the internal Swarm. |
| Public DNS | *.mcp.example.com → the DMZ box's public IP. |
| Internal DNS (split-horizon) | internal.example.com → on-prem Traefik, resolved from the DMZ. |
| Public TLS cert | Wildcard *.mcp.example.com → certs/mcp-wildcard.{crt,key}. |
| Internal CA | If the internal edge uses a self-signed cert: certs/internal-ca.crt (otherwise drop NODE_EXTRA_CA_CERTS + insecureSkipVerify). |
| Firewall | Internet → DMZ:443 and DMZ → internal.example.com:443 ONLY. No database / Redis / overlay access. |
| Public-domain declaration | A single line on the API (see "Declaring the public domain" below), ONLY if that domain differs from the internal domain. On the same domain, nothing to declare. |
vaks.local) while the MCP server is published on a public domain (for
example mcp.example.fr, each organization served at
<slug>.mcp.example.fr). Nothing requires the two to resemble each other.
Declaring the public domain
The MCP server passes the public hostname to the API unchanged; the API derives the
organization from the first segment (<slug>.mcp.example.fr →
slug). One declaration covers every organization on that domain
— there is never a per-tenant entry. This only matters in pooled mode, where
several organizations share one instance and the API has to work out which one a request is
for from its Host. In dedicated mode there is exactly one organization and the
Host is never used to pick a tenant, so this whole declaration is moot there — skip it.
This declaration is required only when the MCP public domain differs from the
internal domain. If the MCP server is published on the same domain as the application (for
example <slug>.mcp.example.com with the application on example.com),
resolution is automatic and nothing is needed.
pooled operator serving customers under more than one public
brand can declare several entirely different domains, and each is resolved the same way —
"publicDomains": ["mcp.example.fr", "mcp.example.io"] lets both
acme.mcp.example.fr and acme.mcp.example.io reach the same tenant.
What this does not give you is one MCP gateway fronting several genuinely
separate installations: each MCP process forwards to exactly one fixed backend (its
VAKS_API_URL), set once at deploy time. Distinct clusters — each with its own
database, its own tenants — each need their own MCP deployment; the multi-domain list only
widens the public reach of one cluster's own tenants, it does not bridge clusters.
| Deployment method | Procedure |
|---|---|
| Generator | The infra/cluster.json manifest describes the cluster. It is created at
first deployment by the bash setup.sh wizard (which now asks for the MCP
public domain), or copied from infra/cluster.example.json.
Fill in the
then regenerate the artifacts: |
| Hand-written stack | Set TENANCY_MCP_DOMAINS on both the api and
controlplane services in docker-stack.yml, where a commented
line serves as a template:
Comma-separated; each entry is matched independently, so unrelated domains are fine. |
bash setup.sh with no argument on an already-installed cluster.
In that form the command restarts the interactive wizard and rewrites
infra/cluster.json from the answers given: any manual change to the manifest is
lost. To rebuild the artifacts after a change, use the manifest form, which reads the file
without rewriting it:
bash setup.sh --manifest infra/cluster.json
Still on the DMZ box over SSH:
1 · Get the image — build or import vaks-pm/mcp:latest on this host (see get the image onto this host above).
2 · Configure the box — in the infra/dmz/ folder of the repository cloned on this host:
cd infra/dmz
cp .env.example .env # edit INTERNAL_EDGE_URL to your split-horizon internal edge
mkdir -p certs
# certs/mcp-wildcard.crt certs/mcp-wildcard.key public *.mcp.example.com cert
# certs/internal-ca.crt internal edge CA (only if self-signed)
The docker-compose.yml sets the key variables: VAKS_API_URL (= INTERNAL_EDGE_URL), VAKS_API_PREFIX=api/v1, VAKS_MCP_LABEL=mcp and VAKS_AUTH_SERVER_MODE=self. Typically only INTERNAL_EDGE_URL needs editing.
3 · Start:
docker compose up -d
docker compose logs -f mcp
4 · Verify — from the internet (replace demo with a real tenant slug):
# Resource metadata (served locally by mcp) — resource + authorization_servers = self
curl -sk https://demo.mcp.example.com/.well-known/oauth-protected-resource | jq
# AS discovery (forwarded to the internal API, URLs rewritten to the public host)
curl -sk https://demo.mcp.example.com/.well-known/oauth-authorization-server | jq
# POST /mcp with no token → 401 + WWW-Authenticate pointing at resource_metadata
curl -ski -X POST https://demo.mcp.example.com/mcp -d '{}' | grep -i www-authenticate
https://<slug>.mcp.<your-domain>/mcp — the public hostname you chose and put in DNS, not an address belonging to us. The client discovers the AS, starts sign-in (forwarded to the internal app), gets its token and calls tools. That hostname (not the admin-panel URL) is what you hand users.
Option B — Internal / co-located default on Swarm
This is what a standard Swarm install already runs, out of the box: the same MCP image, next to the API, on the Swarm overlay network, with the authorization server advertised directly on the tenant address. There is nothing to add. What follows is for confirming it is healthy, or for reproducing the same shape on Kubernetes or a hand-rolled stack.
api/web/worker. Traefik routes /mcp to it and everything else to the rest of the stack; mcp then reaches api directly at http://api:3000 — one hop, no TLS, no separate gateway. This is what a standard install already runs, with no extra step.Confirm it, on an existing Swarm install:
docker service ls | grep mcp
# vaks-pm_mcp replicated 2/2 vaks-pm/mcp:latest
docker service ps vaks-pm_mcp --format "{{.Node}} {{.CurrentState}}"
# two tasks, on two different nodes, both Running
VAKS_API_URL variable), from wherever it runs. It is not a value to look up elsewhere; it depends on where MCP sits:
- On the same Swarm overlay as the API (co-located) → the internal service name:
http://api:3000. This is exactly the value theworkerservice already uses (INTERNAL_API_URL: http://api:3000indocker-stack.yml). No TLS, no Traefik on that hop — you are inside the overlay. On Kubernetes, the equivalent is the API Service in the namespace:http://<release>-api:3000. - On a separate host / in a DMZ → MCP cannot reach
api:3000(overlay-only), so it goes through the internal Traefik via a split-horizon name, e.g.https://internal.example.com— that is theINTERNAL_EDGE_URLof Option A. You choose that name: it must resolve to the on-prem Traefik that already serves the app (the manager IP, or a VIP).
Host header (the tenant host, with the mcp label stripped), so the API resolves the right tenant in pooled mode regardless of the connection URL.
On Docker Swarm — for reference, or to reproduce this on a hand-rolled stack, this is what a standard install already declares:
mcp:
image: vaks-pm/mcp:latest
networks: [vakspm] # the same overlay the `api` service is on
environment:
VAKS_API_URL: http://api:3000 # the API service on the overlay (same value the worker uses)
VAKS_API_PREFIX: api/v1
# VAKS_AUTH_SERVER_MODE is deliberately left unset (defaults to "self").
# On a co-located deployment with no separate mcp.<tenant> address,
# "self" and "tenant" resolve to the exact same origin — setting it
# explicitly would change nothing here.
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"]
interval: 10s
timeout: 3s
retries: 3
start_period: 20s
stop_grace_period: 30s # time to finish in-flight requests before a replica is retired
deploy:
replicas: 2
placement:
preferences: [{ spread: node.id }] # the two replicas land on different nodes
update_config: { parallelism: 1, order: start-first }
labels:
- traefik.enable=true
- "traefik.http.routers.mcp.rule=PathPrefix(`/mcp`) || PathPrefix(`/.well-known/oauth-protected-resource`)"
- traefik.http.routers.mcp.priority=150 # explicit priority, not left to rule-length matching
- traefik.http.services.mcp.loadbalancer.server.port=8080
That router diverts /mcp (and the RFC 9728 metadata) to the MCP container on any tenant host; the incoming Host (e.g. demo.example.com) is preserved, and a single MCP service serves every tenant. You reuse the instance's existing certificate — no *.mcp wildcard or separate cert.
The health check is the part most worth keeping if adapting this by hand: without it, a rolling update can send a client's request to a replica whose process has not finished starting yet, which briefly breaks the connection. With it, Swarm waits for a new replica to answer /healthz before retiring the old one — so upgrading the server is invisible to a connected assistant.
On Kubernetes — the Helm chart does not template MCP (yet), so you apply the manifests alongside it, in the same namespace as the release. A Deployment + Service for the image, plus Ingress paths that send /mcp to that Service on the tenant host. Same logic as Swarm: VAKS_API_URL points at the namespace's API Service, VAKS_AUTH_SERVER_MODE=tenant.
apiVersion: apps/v1
kind: Deployment
metadata: { name: mcp }
spec:
replicas: 2
selector: { matchLabels: { app: mcp } }
template:
metadata: { labels: { app: mcp } }
spec:
containers:
- name: mcp
image: vaks-pm/mcp:latest # your registry / tag
ports: [{ containerPort: 8080 }]
env:
- { name: VAKS_API_URL, value: "http://<release>-api:3000" } # the API Service in this namespace
- { name: VAKS_API_PREFIX, value: "api/v1" }
- { name: VAKS_AUTH_SERVER_MODE, value: "tenant" }
---
apiVersion: v1
kind: Service
metadata: { name: mcp }
spec:
selector: { app: mcp }
ports: [{ name: http, port: 8080, targetPort: 8080 }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: { name: mcp }
spec:
ingressClassName: nginx # same class as the chart Ingress
tls:
- hosts: ["demo.example.com"]
secretName: vaks-pm-tls # same TLS Secret as the chart Ingress
rules:
- host: demo.example.com # the tenant host
http:
paths:
- path: /mcp
pathType: Prefix
backend: { service: { name: mcp, port: { number: 8080 } } }
- path: /.well-known/oauth-protected-resource
pathType: Prefix
backend: { service: { name: mcp, port: { number: 8080 } } }
Find the exact API Service name with kubectl get svc -l app.kubernetes.io/component=api (typically <release>-api). Reuse the same ingressClassName and TLS secretName as the chart's Ingress; the /mcp path wins over / (more specific prefix), so it does not clash with the existing routing. A single MCP deployment serves every tenant: add one host: per tenant you expose.
Option C — Static token, no OAuth
If the authorization server must stay strictly internal, or for a single power user, you can skip the OAuth flow entirely: the user creates a personal API token scoped to mcp:access in their account settings and pastes it into the connector. No browser sign-in, no auth surface to forward. The token is still subject to the same gating (mcp.enabled) and the same permissions as the user.
What is logged
Every MCP action is attributed as an agent action in the audit log — deliberately distinguished from the same person acting in the browser, and from a plain script using the same token. Each write also carries the justification the assistant supplied, a one-line reason for the action, and which assistant made the call (Claude Desktop, ChatGPT, and so on). The audit trail therefore tells you not just what changed, but that an assistant did it, on whose behalf, from which client, and why. Connections themselves are visible and revocable as above.
Troubleshooting
| Symptom | Cause & fix |
|---|---|
| The assistant cannot reach the server at all | Almost always the URL: in a gateway deployment, the admin-panel URL is not the public one. Confirm the actual hostname with your operator. Check too that the assistant, a cloud service, can reach it over the internet. |
| Consent screen refuses, no approve button | The connector is disabled. Enable it in the admin section. |
| The assistant only offers read tools | Writes are off, or the connection predates turning them on. Turn on Allow AI write actions; existing connections pick it up at their next refresh, so a reconnect is the quick fix. |
| Writes worked, then briefly disappeared | A transient loss of contact with the API drops the session to read-only rather than erroring. It restores itself. Persistent loss is worth checking with your operator. |
| Finance is invisible to the assistant | Expected unless Expose finance to AI is on — and even then only for users who already have finance access. |
| The user sees French error text in their assistant | Assistant-facing tool descriptions and some runtime messages are in French in this release, while the admin and consent screens are English. It is cosmetic; the behaviour is unaffected. |
| Copilot Studio cannot connect | It does not self-register, and has its own licensing and per-user pitfalls. See the Copilot Studio guide. |
| The assistant asks the user to confirm before deleting something | Expected — see confirmation & rate limits. Set the policy to Off if you would rather it not ask. |
| A large export (e.g. a full personal-data download) seems to hang | Large requests run as a background job: the assistant starts it, then polls for completion rather than holding one connection open. For a very large tenant this can take a little while — it is working, not stuck. |
Per-assistant guides: Copilot Studio · Claude · ChatGPT. Related: OIDC single sign-on, reused for MCP login · product & features for the agent governance model · all integrations.