Skip to content
Conduit

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

Juxn89/talent-mcpcommunitylistedunreviewed

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/demo are 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
  1. 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.
  2. Reads are pure functions over Postgres data: the same question always returns the same answer, with the breakdown behind every score.
  3. State between calls is a signed, time-limited handle passed back as an ordinary argument, so any replica can answer any request.
  4. 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:

Keycloakhttp://localhost:8080 — admin / admin
Realm discoveryhttp://localhost:8080/realms/talent/.well-known/openid-configuration
Grafanahttp://localhost:3000 — dashboards provisioned from deploy/grafana/
Jaegerhttp://localhost:16686
Prometheushttp://localhost:9090
Lokihttp://localhost:3100
OTLP intakelocalhost:4317 (gRPC), localhost:4318 (HTTP)
Postgreslocalhost:5432 — talent / talent, databases talent and keycloak
Keycloak realm userrecruiter / 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.

ToolScopeWhat it demonstrates
search_jobstalent.jobs.readPagination by signed handle — the pattern that replaces sessions
get_jobtalent.jobs.readCacheable result with ttlMs/cacheScope, plus region routing promoted to a header via [McpHeader("Region")]
extract_skillstalent.jobs.readTaxonomy normalization, deterministic and LLM-free
score_candidate_fittalent.candidates.readExplainable score with a per-component breakdown
reject_candidatetalent.candidates.rejectA destructive operation gated on MRTR confirmation, including the degraded path
bulk_score_shortlisttalent.candidates.writeLong-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…" }

hasMore is an explicit field on purpose. The serializer omits nulls, so on the last page nextPageHandle is absent, not null — 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.
ProjectRole
Talent.DomainEntities and the pure scoring and normalization functions. Zero framework dependencies
Talent.ApplicationUse cases and ports (IJobRepository, ICandidateRepository, IHandleCodec)
Talent.InfrastructureAdapters: EF Core/Npgsql, migrations, seeds, Keycloak, OTel exporters
Talent.Mcp.ToolsThe six tool types and their wire contracts, shared verbatim by both hosts
Talent.Mcp.ServerASP.NET Core Streamable HTTP host → the GHCR image
Talent.Mcp.Server.Stdiostdio host → the talent-mcp dotnet tool
Talent.Mcp.ToolkitDomain-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:

ConfigurationTime to servingPeak RSSSizeShipped?
Installed dotnet tool323 ms84.8 MB20.9 MByes
Framework-dependent publish324 ms84.9 MB12.5 MB—
Self-contained, ReadyToRun177 ms89.0 MB105.4 MB—
Self-contained, trimmeddoes 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.

SettingEnvironment variablePurpose
ConnectionStrings:TalentConnectionStrings__TalentPostgres, in Npgsql keyword format (not a URI)
Talent:HandleSigningKeyTalent__HandleSigningKeyBase64, at least 32 bytes. Signs pagination and confirmation handles
Talent:Auth:AuthorityTalent__Auth__AuthorityOIDC issuer, e.g. http://localhost:8080/realms/talent
Talent:Otel:EndpointTalent__Otel__EndpointOTLP collector, e.g. http://localhost:4317
Talent:DefaultPageSizeTalent__DefaultPageSizeDefault page size (20)
Talent:MaxPageSizeTalent__MaxPageSizePage size ceiling (100)
Talent:Database:MigrateAndSeedOnStartupTalent__Database__MigrateAndSeedOnStartupApply 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:

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.

↑↓ navigate openesc close