mcp server
talent-mcp
MCP server in C# for the recruitment domain — typed tools for job search, candidate-fit scoring, and skill extraction, built against the 2026-07-28 Model Context Protocol revision. Stateless Streamabl
Set up
Runs on your computer. Pick your agent and operating system.
This project does not publish an install method we can turn into a config. Follow the README instead.
README
synced Oct 28, 2026
AI agents can now call tools: search your data, create records, take actions. Connecting one to a real
system raises the questions that matter — what it may read, what it may change, who confirms before
something is deleted, and how you audit what it did. talent-mcp answers them for a domain where
mistakes are costly, recruiting: an MCP server in C# with six typed tools, a separate OAuth scope for
reading, writing and destroying, and a confirmation round trip before any rejection. It is built
against the 2026-07-28 Model Context Protocol revision — the one that removed sessions — over
stateless Streamable HTTP, and ships the same six tools over stdio as a dotnet tool.
[!NOTE] No tool calls an LLM. Scoring and skill normalization are deterministic pure functions, so the server runs with no API keys, at zero cost, and its results are reproducible enough to be a test oracle. The screenshots in
docs/demoare the running server seen through MCP Inspector 2.9.0; every number on this page comes from CI or the changelog.
How it works
flowchart LR
A([AI agent]) --> T["OAuth 2.1 token<br/>scope per tool"]
T --> S{{"Scope allows<br/>this tool?"}}
S -- no --> D["Denied<br/>before the tool runs"]
S -- yes --> R["Typed tool<br/>pure scoring · signed handles"]
R --> X{{"Destructive?"}}
X -- no --> O(["Result<br/>+ next-page handle"])
X -- yes --> C["input_required<br/>confirm and reason"]
C --> W(["Confirmed retry<br/>writes to Postgres"])
classDef io fill:#1e40af,stroke:#38bdf8,color:#ffffff
classDef work fill:#1e293b,stroke:#64748b,color:#f8fafc
classDef gate fill:#b45309,stroke:#fbbf24,color:#ffffff
class A,O,W io
class T,R,C work
class S,X gate
class D gate
- The agent gets a token from Keycloak whose scopes name what it may do; each tool checks its own scope, and a token without it is denied before the tool runs.
- Reads are pure functions over Postgres data: the same question always returns the same answer, with the breakdown behind every score.
- State between calls is a signed, time-limited handle passed back as an ordinary argument, so any replica can answer any request.
- A destructive tool answers with a question first. Only the client's confirmed retry performs the write, and the reason travels inside the signed request state, so it cannot change in between.
What's inside
Run it
git clone https://github.com/Juxn89/talent-mcp.git && cd talent-mcp
docker compose -f deploy/compose.yaml up -d --wait
dotnet run --project src/Talent.Mcp.Server
That brings up Postgres, Keycloak with the realm imported, and the full OpenTelemetry stack:
| Keycloak | http://localhost:8080 — admin / admin |
| Realm discovery | http://localhost:8080/realms/talent/.well-known/openid-configuration |
| Grafana | http://localhost:3000 — dashboards provisioned from deploy/grafana/ |
| Jaeger | http://localhost:16686 |
| Prometheus | http://localhost:9090 |
| Loki | http://localhost:3100 |
| OTLP intake | localhost:4317 (gRPC), localhost:4318 (HTTP) |
| Postgres | localhost:5432 — talent / talent, databases talent and keycloak |
| Keycloak realm user | recruiter / recruiter |
Every credential above is a dev-only default and every one is overridable from the environment.
Image tags are pinned to exact patch versions, because latest makes a green CI run
unreproducible three weeks later.
Running under Development also applies the migrations and inserts realistic seed jobs and
candidates, so search_jobs has something to find on the first call. That is
Talent:Database:MigrateAndSeedOnStartup, and it is off anywhere else: the HTTP host can run as
several replicas whose migrations would race, the stdio host is launched once per client session on a
path where the check would cost every session, and a schema change should be a deploy step somebody
ran rather than a side effect of a process starting. Seeding is idempotent, so a restart on an
existing volume inserts nothing.
To apply the schema without the flag — a real deployment, say — the ordinary EF path still works:
dotnet ef database update --project src/Talent.Infrastructure --startup-project src/Talent.Mcp.Server
docker compose -f deploy/compose.yaml down # add -v to drop the volume and force a realm re-import
The MCP server is deliberately not a compose service: the conformance and E2E suites build it in-process so they can assert against the same composition the tests exercise.
The tools
Each one exists to demonstrate a specific capability of the revision. None is decorative.
| Tool | Scope | What it demonstrates |
|---|---|---|
search_jobs | talent.jobs.read | Pagination by signed handle — the pattern that replaces sessions |
get_job | talent.jobs.read | Cacheable result with ttlMs/cacheScope, plus region routing promoted to a header via [McpHeader("Region")] |
extract_skills | talent.jobs.read | Taxonomy normalization, deterministic and LLM-free |
score_candidate_fit | talent.candidates.read | Explainable score with a per-component breakdown |
reject_candidate | talent.candidates.reject | A destructive operation gated on MRTR confirmation, including the degraded path |
bulk_score_shortlist | talent.candidates.write | Long-running work on the Tasks extension, backed by Postgres so it survives a restart |
Scopes are enforced per tool — read, write and destructive are not interchangeable — and the E2E suite asserts that a token missing the required scope is denied.
The flow, end to end
What makes this revision different, in the order you hit it. Request bodies below are the real wire shapes; run the commands against your own stack for live values.
1. Get a token. Every tool call needs one, and the scope has to match the tool.
TOKEN=$(curl -s -X POST http://localhost:8080/realms/talent/protocol/openid-connect/token \
-d grant_type=password -d client_id=talent-mcp-client \
-d username=recruiter -d password=recruiter \
-d scope='talent.jobs.read talent.candidates.read' | jq -r .access_token)
2. Search, and page with a handle — not a session. There is no Mcp-Session-Id and no
initialize. State between calls travels as a signed, TTL-bounded handle passed back as an ordinary
tool argument.
curl -s -X POST http://localhost:5000/mcp \
-H "Authorization: Bearer $TOKEN" \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' -H 'Mcp-Name: search_jobs' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"search_jobs",
"arguments":{"query":"engineer","pageSize":2},
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}'
The result carries nextPageHandle, which you pass straight back as an argument to get page two:
{ "jobs": [ /* … */ ], "totalMatches": /* … */, "hasMore": true,
"nextPageHandle": "AAAAAGqQKRi4yPyKLkvv9…" }
hasMoreis an explicit field on purpose. The serializer omits nulls, so on the last pagenextPageHandleis absent, notnull— a client that treats "no more data" as the absence of another field is reading a serialization detail as a protocol signal.
3. Reject a candidate — and get told no. The first call does not delete anything. It raises
InputRequiredException, which reaches the client as an input_required result carrying a
requestState handle:
# … "name":"reject_candidate", "arguments":{"candidateId":"…","reason":"Withdrew"}
The client re-sends the original request with inputResponses filled in, and only then does the
rejection happen. That round trip is MRTR, and it is the only mechanism the revision leaves for a
server to ask a question — server-initiated requests are gone.
If the client cannot elicit, the tool takes a degraded path instead of failing. The guard is
IsMrtrSupported && ClientCapabilities?.Elicitation is not null: the first asks whether the
mechanism exists, the second whether anyone is there to answer.
4. Watch it in Jaeger. Client context arrives in _meta as traceparent/tracestate/baggage,
so one trace spans client → tool → Postgres: Npgsql's command spans hang off the tool span. Open
http://localhost:16686 and pick talent-mcp-http; Grafana has latency and errors per tool.
Architecture
Clean Architecture, with the dependency rule verified in CI rather than agreed to in a document.
Presentation ──┐
├──▶ Application ──▶ Domain
Infrastructure ┘
Dependencies point inward only. Domain references nothing.
| Project | Role |
|---|---|
Talent.Domain | Entities and the pure scoring and normalization functions. Zero framework dependencies |
Talent.Application | Use cases and ports (IJobRepository, ICandidateRepository, IHandleCodec) |
Talent.Infrastructure | Adapters: EF Core/Npgsql, migrations, seeds, Keycloak, OTel exporters |
Talent.Mcp.Tools | The six tool types and their wire contracts, shared verbatim by both hosts |
Talent.Mcp.Server | ASP.NET Core Streamable HTTP host → the GHCR image |
Talent.Mcp.Server.Stdio | stdio host → the talent-mcp dotnet tool |
Talent.Mcp.Toolkit | Domain-agnostic protocol primitives → NuGet |
Talent.Mcp.Tools references neither Talent.Infrastructure nor ModelContextProtocol.AspNetCore,
which is what lets the stdio host serve the identical surface without dragging the web stack into a
process where cold start is the whole point. Talent.Architecture.Tests fails the build if that
changes.
Testing
Five levels with distinct jobs. All five gate the merge — they do not merely report.
# No Docker needed
dotnet test tests/Talent.Architecture.Tests # the dependency rule
dotnet test tests/Talent.Domain.Tests # pure scoring and normalization
dotnet test tests/Talent.Mcp.Tests # tools over the in-memory transport
# Needs the stack
docker compose -f deploy/compose.yaml up -d --wait
dotnet test tests/Talent.Infrastructure.Tests # EF mapping against real Postgres
dotnet test tests/Talent.Mcp.Conformance # protocol conformance for 2026-07-28
dotnet test tests/Talent.Mcp.E2E # real client → HTTP → OAuth → Postgres
dotnet test # everything
The conformance suite carries the most signal. It asserts that tools/list returns all six tools
by name — not merely that the server starts. A tool set silently emptied by trimming answers
-32601 with no crash and no error log, which is this project's worst failure mode and the reason
tools are registered explicitly rather than by assembly scan.
Benchmarks
dotnet run --project bench/Talent.Mcp.Bench -c Release
Both targets are pure functions over Talent.Domain — no repository, no DbContext, no Docker —
which is what makes the numbers reproducible rather than a description of one machine's Postgres.
CandidateFitScorer.Score is measured for a single pair and across a 500-candidate shortlist, the
ceiling bulk_score_shortlist works to. SkillNormalizer.Extract is measured across three CV
lengths, because its cost is proportional to alias count times text length rather than flat.
Cold start and memory for the stdio host are measured separately, in CI rather than on a laptop, by
the startup-benchmark job:
| Configuration | Time to serving | Peak RSS | Size | Shipped? |
|---|---|---|---|---|
Installed dotnet tool | 323 ms | 84.8 MB | 20.9 MB | yes |
| Framework-dependent publish | 324 ms | 84.9 MB | 12.5 MB | — |
| Self-contained, ReadyToRun | 177 ms | 89.0 MB | 105.4 MB | — |
| Self-contained, trimmed | does not start | — | 39.9 MB | — |
~323 ms is what you actually pay. The apphost shim and muxer resolution that dotnet tool install adds cost nothing measurable over a bare publish directory, which was not the expectation.
ReadyToRun nearly halves that, but it is not currently shippable where it would matter: cold start is a per-session cost for the stdio host, and that host reaches you as portable IL, which R2R would trade for a package per platform. Applying it to the container image instead would be theatre — that is a long-lived server whose startup is paid once.
Trimming is not merely slower: the host throws at startup, because the SDK builds tool schemas by reflecting over parameter types and a domain enum has no metadata once trimmed. The trim analyzer never warned — it reported two unrelated EF Core diagnostics. ADR-0007 has the stack trace and what reversing it would cost.
Results and what they mean are in
docs/verification/domain-benchmarks.md. The short
version: scoring a full 500-candidate shortlist costs about a millisecond, so whatever makes
bulk_score_shortlist long-running is the data access around it, not the scorer. Allocation figures
there are byte-identical across runs; the wall-clock ones were taken on a throttled laptop and are
reported as orders of magnitude, not as measurements.
Configuration
Settings bind from appsettings.json, environment variables, or any other IConfiguration source.
Environment variables use __ for the : separator.
| Setting | Environment variable | Purpose |
|---|---|---|
ConnectionStrings:Talent | ConnectionStrings__Talent | Postgres, in Npgsql keyword format (not a URI) |
Talent:HandleSigningKey | Talent__HandleSigningKey | Base64, at least 32 bytes. Signs pagination and confirmation handles |
Talent:Auth:Authority | Talent__Auth__Authority | OIDC issuer, e.g. http://localhost:8080/realms/talent |
Talent:Otel:Endpoint | Talent__Otel__Endpoint | OTLP collector, e.g. http://localhost:4317 |
Talent:DefaultPageSize | Talent__DefaultPageSize | Default page size (20) |
Talent:MaxPageSize | Talent__MaxPageSize | Page size ceiling (100) |
Talent:Database:MigrateAndSeedOnStartup | Talent__Database__MigrateAndSeedOnStartup | Apply migrations and seed at startup. Off unless set; on in Development |
The connection string, signing key and issuer are not in appsettings.json on purpose:
production supplies all three from the environment and the host refuses to start without them. Dev
values live in appsettings.Development.json, where the signing key is a labelled placeholder — it
decodes to an ASCII string. Never deploy with it; anyone who can read the file can forge a handle.
The stdio host needs a connection string too. It is not a thin client: it reaches Postgres through
the same adapters as the HTTP host, so only extract_skills works without one. It honours the same
seeding flag, which is worth turning on once when pointing talent-mcp at a fresh database and off
again afterwards.
Install
As a dotnet tool (stdio, for Claude Code and Claude Desktop):
dotnet tool install --global Talent.Mcp.Server # provides the `talent-mcp` command
talent-mcp
Note the package id is Talent.Mcp.Server and the command is talent-mcp.
Talent.Mcp.Toolkit is a library, not a
tool — it is the reusable protocol layer: signed handles, a Postgres IMcpTaskStore, cache policies,
and OTel context extraction from _meta.
See docs/CLAUDE_INTEGRATION.md for client configuration, docs/INSTALLATION.md for every setup path, and docs/DEVELOPMENT.md for the local workflow.
As a container:
docker run -p 5000:5000 ghcr.io/juxn89/talent-mcp:latest
Releasing is tag-driven — git tag v1.2.3 && git push origin v1.2.3 publishes both packages, the
image, and a GitHub release. The tag sets the version: CI passes it to dotnet build and
dotnet pack, so the package, the assembly inside it and the tag cannot drift apart. Both packages
go out at the tag's version; splitting them would need package-scoped tags, which is a change to make
when the library first needs a major the tool has not earned.
Decisions
The reasoning, including what was tried and rejected:
- ADR-0001 · Streamable HTTP session mode — why
Statelessis set explicitly - ADR-0002 · Native AOT and explicit tool registration — trimming silently empties a reflection-discovered tool set, measured
- ADR-0003 · Cross-node task input responses —
LISTEN/NOTIFYplus a sweep, because a stateless server answers the follow-up on a different node - ADR-0004 · One tool surface, two hosts — and why the stdio host takes EF Core deliberately
- ADR-0005 · Pre-registration, not DCR or CIMD
- ADR-0006 · Observability instrumentation — why
AddCallToolFilternever fires for a registered tool - ADR-0007 · Trim-clean over Native AOT
Dated verification records live in docs/verification/ — what was checked,
against which source, on what date. Grep there before re-verifying something.
The full plan, which is the source of truth for scope and phases, is
docs/plans/a2-talent-mcp.md. Contributor and agent guidelines are
in AGENTS.md. Release history is in CHANGELOG.md.
How it was built
Developed with Claude Code under explicit rules rather than ad-hoc prompting: AGENTS.md
holds the binding guidelines and CLAUDE.md points to it. Every decision has an ADR in
docs/adr/, and every external fact the code relies on has a dated record in
docs/verification/. The README artwork is generated by
scripts/readme-assets.mjs (node scripts/readme-assets.mjs, no
dependencies).
Juan Gómez · juangomezb.com · GitHub · LinkedIn
Released under the Apache 2.0 License.