think
16px
820px

The MCP server

Lets an AI agent work the hiring pipeline: read candidates and their DISC
results, add people, write interview notes, assemble batches.

The portal serves it directly, at https://recruitment.val.id/api/mcp.
There is nothing to install.

Two ways in, and which you need depends on the client:

  • Claude Desktop — add the URL as a custom connector and approve it in the
    browser. No token to copy anywhere. Go straight to step 2.
  • Claude Code, scripts, anything scripted — mint a token below and send it
    as a header.

1. Mint a token (for Claude Code and scripts)

Console → SettingsService tokens. Give it a name (it appears on
anything the agent writes), tick the scopes, and copy the secret. It is shown
once; only its SHA-256 is stored.

Scope What it reaches
pipeline:read Candidates, DISC results, interview notes, batches
pipeline:write Add candidates, edit stage/rating/hold, write notes
batches:write Create batches, record recommendations

Start with pipeline:read. The endpoint offers only the tools the token can
use, so a read-only token produces a server with no write tools on it at all —
a stronger guarantee than an agent choosing not to call them.

No token can send a candidate mail, issue or resend an assessment, read
back an invitation link and PIN, delete or archive a candidate, upload or
download a CV, change any setting, or mint another token. Those limits are in
the server, so ticking every scope is still safe.

2. Connect

Claude Code

claude mcp add --transport http valid-disc https://recruitment.val.id/api/mcp \
  --header "Authorization: Bearer vdp_…"

--scope user puts it in every project; the default is this project only.
Check it with /mcp.

Do not use --scope project with a live token — that writes it into .mcp.json
and commits a credential to git history.

Claude Desktop

Settings → ConnectorsAdd custom connector, URL
https://recruitment.val.id/api/mcp. Nothing else to fill in.

The portal is its own OAuth authorization server, so Desktop discovers it,
registers itself, and sends you to a consent screen. You sign in through Casdoor
exactly as you do for the console, see in plain words what the connector is
asking for, and approve or cancel. Restart Desktop fully after adding a
connector — ⌘Q, not just closing the window.

What you get is a grant rather than a shared secret: it is tied to you, it says
so in the console list ("Claude Desktop — Efran"), it renews itself while in
use, and it stops renewing after 30 idle days. You can only approve what your
own role carries — a read-only role cannot grant a connector write access.

Skip step 1 entirely if you use this route. Mint a token by hand only for a
script, or for a client that cannot do the authorisation dance.

3. Check it

Curl is the fastest way to know the endpoint and the token are both good:

curl -sS https://recruitment.val.id/api/me -H "Authorization: Bearer vdp_…"
{"authenticated":true,"kind":"service-token","name":"Claude agent",
 "scopes":["pipeline:read"],"expiresOn":"16 Nov 2026"}

If that works and a client still cannot connect, the problem is the client's
auth configuration, not the token. For an OAuth grant, the same call works with
its access token and reports "kind":"service-token" too — a grant is one.

Message Cause
{"authenticated":false} No token reached the server — the header was not sent
That service token has been revoked Someone pressed Revoke in the console
That service token expired on … A minted token does not renew. Mint another, or use a connector
does not have a scope for this The token lacks the scope that route needs

The local binary

cmd/mcp is a standalone stdio server: the same tools, through the same API,
with the same token. It exists for a client that only launches local processes,
or a machine that reaches the portal through a tunnel. It is not the normal
way in
— prefer the hosted endpoint.

make mcp                    # linux, into bin/disc-mcp
make mcp-darwin             # apple silicon, for a Mac running Claude Desktop

Static, so it needs nothing installed on the far side. Put it at an absolute
path — /usr/local/bin/disc-mcp — and on macOS clear the quarantine flag:
xattr -d com.apple.quarantine /usr/local/bin/disc-mcp.

Claude Desktop, in claude_desktop_config.json (macOS
~/Library/Application Support/Claude/, Windows %APPDATA%\Claude\, Linux
~/.config/Claude/):

{
  "mcpServers": {
    "valid-disc": {
      "command": "/usr/local/bin/disc-mcp",
      "env": {
        "DISC_PORTAL_TOKEN": "vdp_…",
        "DISC_PORTAL_URL": "https://recruitment.val.id"
      }
    }
  }
}

Three things bite here, all the same mistake: Desktop does not read your shell
environment, does not resolve PATH, and does not reload the file. Absolute
path, every variable inside env, full restart. Its log is at
~/Library/Logs/Claude/mcp-server-valid-disc.log, and the server writes a
startup line naming the token, its scopes and its expiry.

Claude Code: claude mcp add valid-disc --env DISC_PORTAL_TOKEN=vdp_… -- /usr/local/bin/disc-mcp.

The tools

Read — pipeline:read:

  • list_candidates — the pipeline, with stage, DISC pattern, rating, hold
  • get_candidate — one person in full, including the three DISC graphs, the
    pattern code and its reading
  • search_candidates — filter by name, role, stage, pattern, rating, hold
  • list_batches, get_batch — the comparison sheets

Write — pipeline:write:

  • create_candidate, update_candidate, add_note

Batches — batches:write:

  • create_batch, set_batch_verdict

The tool descriptions carry the rules the portal enforces — that a batch is the
group taken to management and holds nobody without a DISC result, that
talent_pool means kept warm rather than rejected, that this portal does not
email rejections. An agent reads those before it acts; the server refuses
regardless.

How authorisation works, and why it is not doubled

A tool call arrives as one POST /api/mcp. That request is not where anything
is decided.

The endpoint turns each tool into an ordinary request — PATCH /api/candidates/{id}, say — and runs it through the portal's own router, in
process, carrying the same token. The router matches the pattern, the guard
checks that pattern against the token's scopes, and the handler that runs is the
one the browser console calls. So there is one place that decides what an agent
may do, and it is the place that already decided what a person may do.

That is why connecting to /api/mcp grants nothing on its own, and why a
read-only token can hold an open session and still be refused the moment it
asks for a write.

How the OAuth flow works

Worth knowing because it is unusual in one respect: the portal is the
authorization server, but it is not the thing that authenticates you.

  1. The client gets a 401 from /api/mcp carrying
    WWW-Authenticate: … resource_metadata="…/.well-known/oauth-protected-resource".
  2. It reads that document, then the authorization-server metadata beside it.
  3. It registers itself (RFC 7591) — no client ID configured by hand at either
    end. Registration grants nothing; it creates a name and a redirect URI.
  4. It opens /oauth/authorize in your browser. That route is behind the same
    Casdoor session as the console
    , so you sign in as yourself, and the consent
    screen names you.
  5. You approve. The scopes you may delegate are checked against the same Casbin
    policy the console uses — nobody hands out access they do not hold.
  6. The client exchanges the code, with PKCE, for an access token and a refresh
    token. Both are rotated on every refresh.

Public clients only, S256 PKCE only, authorization code and refresh only. Plain
PKCE is refused, redirect URIs are matched exactly, and a refusal about an
unregistered redirect is answered on the portal's own page rather than sent to
the URI in question.

The credential that comes out is a row in the same table as a hand-minted
token — so it goes through the same guard, the same scope check, the same
console list and the same revoke button. There is one enforcement path, not two.

Revoking

Console → Settings → Service tokens → Revoke. Nothing caches the row and
the endpoint holds no session state, so the agent's next tool call is the
refused one — mid-conversation, not at its next connection. The row stays in the
list, with who revoked it and when.

That is the same button for a hand-minted token and for an OAuth grant.
Re-authorising a connector also revokes whatever it held before, so approving it
twice leaves one live credential rather than two.