Skip to main content

MCP Server

Wayd exposes its API as agent tools in two ways: the hosted endpoint at /mcp, served by Wayd.Web.Api, and the @wayd/mcp npm package, which is being retired once the hosted endpoint has replaced it.

The hosted endpoint​

/mcp serves MCP over Streamable HTTP, statelessly, with the official C# SDK (ModelContextProtocol.AspNetCore). It is behind the mcp-server feature flag and requires an authenticated caller: a personal access token in x-api-key, the Wayd JWT, or an access token from Wayd's own authorization server (below). The code lives in Wayd.Web.Api/Mcp/.

Discovery follows the MCP authorization spec. An unauthenticated call is refused with a 401 whose WWW-Authenticate: Bearer resource_metadata="…" header points at /.well-known/oauth-protected-resource/mcp, the endpoint's protected resource metadata (RFC 9728), which names the API's public URL as the authorization server; a refused bearer token adds error="invalid_token". McpAuthentication owns both: the endpoint's policy authenticates through the Mcp policy scheme, which forwards to the scheme the API's default policy would pick for the request and challenges with the SDK's MCP scheme, so a refusal carries exactly one challenge. The metadata path is fixed, so /mcp/ and /MCP are pointed at the same document and no other path under the prefix is answered. While the flag is off, McpFeatureGate answers 404 for those paths and the authorization server's ahead of authentication, so an unauthenticated caller is not challenged either. The challenge's and the metadata's URLs come from the API's public URL, not from the request, so they are right behind a TLS-terminating proxy. Wayd's subclass of the SDK's handler writes the whole challenge, so it can use the public URL, and replaces whatever WWW-Authenticate value was set before; that is safe only while the MCP scheme is the one scheme that challenges on /mcp. The public URL is an origin with no path, because the well-known documents OAuth clients look up sit at the host's root.

Wayd is the authorization server it names. AuthorizationServerMetadata serves /.well-known/oauth-authorization-server (RFC 8414) with the public URL as the issuer, endpoints under /oauth, the authorization code and refresh token grants for public clients only, and PKCE by S256 alone. Clients register through Client ID Metadata Documents rather than Dynamic Client Registration, which the MCP spec deprecates: a client_id is the HTTPS URL of a JSON document listing the client's name and redirect URIs, and IOAuthClientResolver (Wayd.Infrastructure/Auth/OAuth/) fetches and checks it. Whoever starts an authorization request chooses that URL, so the fetch is treated as hostile:

  • Public addresses only. PublicNetwork refuses any connection to a private, loopback, link-local, multicast or otherwise non-public address, checked when the socket connects so DNS rebinding cannot slip past. It uses an HttpClient of its own: the factory's defaults add service discovery, which would resolve a client-chosen host name to an internal service, and retries.
  • Small and short. No redirects, no proxy, 5 KB, and 5 seconds for the whole fetch — body included, which HttpClient.Timeout alone would not cover once the headers arrive.
  • Not repeatable on demand. A document is cached for its max-age, held between 5 minutes and 24 hours (1 hour if it gives none); a refusal is cached for a minute; concurrent requests for one client share one fetch; and the cache is the resolver's own, capped at 1,000 clients. Otherwise retrying an authorization request would make Wayd fetch, again and again, from a host the requester chose.
  • Exact. The client_id must be HTTPS with a path, and no credentials, fragment, whitespace, backslash or dot segment. The document must give that URL as its own client_id, describe a public client (token_endpoint_auth_method absent or none), and list redirect URIs that are HTTPS, HTTP to the loopback interface, or an app's own scheme — not javascript:, data:, intent:, ms- handlers or the like. Reasons for refusing are written for an OAuth error_description and repeat nothing from the document.

The authorization code flow, end to end. AuthorizationServer (Wayd.Infrastructure/Auth/OAuth/) does the work and AuthorizationServerEndpoints serves it:

  1. GET /oauth/authorize requires response_type=code, client_id, redirect_uri, state, and code_challenge with code_challenge_method=S256; resource (RFC 8707) is optional and must be /mcp under the public URL. The redirect URI must be one the client's document lists, exactly, with one exception (RedirectUriMatch): a plain-HTTP redirect to 127.0.0.1, [::1] or localhost may name any port, registered with one or not, because a desktop client listens on whichever port is free (RFC 8252 §7.3). localhost is included although RFC 8252 prefers the IP literals, because clients such as Claude Code register it. The code is bound to the redirect URI as requested, port included. The document is fetched only after every cheaper check passes, so a malformed request never makes Wayd fetch a URL the requester chose. Every error is shown to the user as a bare HTML page and never redirected. RFC 6749 §4.1.2.1 forbids redirecting while the client or redirect URI is in doubt; beyond that, anyone can be a client with Client ID Metadata Documents and name any redirect URI, so redirecting a protocol error before the user has seen who is asking would make Wayd's domain an open redirector (RFC 9700 §4.11.2). The client hears back only after the user decides: code or error=access_denied, with state and iss (RFC 9207). A valid request becomes a row in Identity.OAuthAuthorizations, and the browser goes to the web app's consent page (ApiSettings:WebClientUrl + /oauth/consent?request=…) carrying a random handle. Only the handle's hash is stored, and the request's parameters never reach the page.

  2. The consent page calls GET /api/oauth/authorization-requests/{handle} to show who is asking, then …/approve or …/deny as the signed-in user, and sends the browser to the redirectUrl it gets back. Only the web app's session may call these; a personal access token gets 403, since approving would turn it into a connector session that outlives it. The client's name is its own claim, so the API strips control, format (bidi, zero-width) and separator characters, keeps at most two combining marks per character, caps it at 60 characters, and returns beside it the client_id's host and whether a web redirect URI's host differs (a loopback or app-scheme redirect stays on the user's machine, so it is not flagged). A request waits ten minutes for a decision and can be answered once.

    The page (src/app/oauth/consent/page.tsx in the client) shows the client_id host as the identity and the name only as what the client calls itself, rendered as text. It runs without the app shell (AuthGate in app/_components/auth-gate.tsx), and next.config.mjs sends frame-ancestors 'none' and X-Frame-Options: DENY for this route alone, so no other site can frame it and overlay Approve; framing headers on every route would break silent sign-in, which loads /login in a same-origin iframe. Headers cannot stop a site that opens the page in a window and uncovers it under the cursor, so Approve stays disabled until the page has been visible and focused for half a second, and again after it loses either (use-approve-armed.ts). A signed-out visitor gets the login page in place: storeReturnUrl (src/utils/return-url.ts) keeps the path and query, and every sign-in path navigates to takeReturnUrl() with a full page load. That accepts only a same-origin relative path whose resolved form does not begin //, so the stored value cannot become an open redirect. A shell-free page reached while signed in discards the stored value, which would otherwise send a later visit back to a spent request.

  3. Approval issues an authorization code bound to the user, client, redirect URI, PKCE challenge and resource, redeemable for 60 seconds, again stored only as a hash.

  4. POST /oauth/token (form-encoded, no client authentication) redeems the code. The code is spent whether or not the client_id, redirect_uri, PKCE verifier and resource match, so a wrong verifier cannot be retried against it. A successful redemption marks the code spent, records its session and creates that session in one save, so a code presented a second time, even in a race with the first, revokes the session it opened. The user must still be active and linked to their login provider, as on every web refresh. Errors follow RFC 6749 §5.2.

  5. Tokens. The access token is a JWT typed at+jwt (RFC 9068) with iss the public URL, aud the /mcp resource, sub the user, client_id the client and sid its session, signed with the web app's key and living as long as its tokens. The /mcp scheme looks the session up on every request, so revoking it ends the token at once. The refresh token opens a session of its own in Identity.UserRefreshTokens, one per authorization, so the same client connected from two machines keeps both, and the user sees each connector in their sessions list (named by the client, with its ClientId) and can revoke it. Its token is {sessionId}.{secret}, because the token endpoint is not told the user; the id only locates the row and the secret is still checked against its salted hash. Refresh rotates it through RefreshTokenStore.RotateForClient with the same reuse detection as the web app, and only the client it was issued to can use it; the web app's refresh never matches a client's session. The user is checked before the token rotates, so refusing a disabled user leaves the token usable if they are enabled again.

Only /mcp accepts these tokens. The Mcp policy scheme forwards a bearer token typed at+jwt to the OAuthAccessToken scheme, which accepts only that type, from this API's issuer, for the /mcp audience. No other policy names that scheme, and the LocalJwt scheme refuses the token on its type, issuer and audience.

A tool call made with one is delegated in process. ApiPipeline does not forward the access token, which the API's endpoints refuse. It mints a token for the inner request instead (DelegatedAccessTokens, Wayd.Infrastructure/Auth/OAuth/), using RFC 8693's claims but not its endpoint: sub the user, act {"client_id": …} the client, aud the API (SecuritySettings:LocalJwt:Audience), iss the public URL, typed at+jwt, signed with the web app's key and living 60 seconds. It carries no may_act, which belongs on a token presented to an exchange endpoint. The request carries the token only in the InProcessDelegation request feature, which no HTTP request can set, and never in a header, so nothing that reads or logs headers can carry it out. PermissionPolicyProvider sends a request with that feature to the DelegatedAccessToken scheme, which reads the token from the feature and refuses a request without it, so a token copied out of the process authenticates nothing. LocalJwt refuses it on its type, which also keeps it from passing IsWebAppSession on the consent API, and the /mcp scheme on its audience. The scheme then loads the user, refusing one deactivated since the client was approved, which the /mcp scheme does not check, and builds the principal as the personal access token scheme does, plus the acting client (ICurrentUser.ClientId). A personal access token or the web app's token is forwarded as it came.

The activity log names the client. BaseDbContext stamps ICurrentUser.ClientId on every activity log entry a save writes (ActivityLogEntry.ClientId), as it stamps the correlation id: it records how the request reached Wayd, not what the user did, so the event, its actor (still User, with the user's employee) and its contract are unchanged. A reaction handled later in its own scope has no client. A personal access token's calls have none either, since nothing tells Wayd which tool holds it.

Tool calls have their own telemetry. The inner request bypasses the hosting layer, so ASP.NET Core's request metrics and spans never see it. ApiPipeline records a tools/call {tool} span and the wayd.mcp.tool.duration histogram under the Wayd.Mcp source and meter (McpTelemetry), tagged with the tool, the credential (oauth, personal_access_token or session) and the status code, which separates MCP load from direct API load. The span also carries the OAuth client; the metric does not, because anyone can be a client and the dimension would have no bound.

Both endpoints are anonymous and rate-limited per caller address (RateLimitSettings, see Rate limits); the authorization endpoint more tightly, because it makes Wayd fetch a URL the caller chooses.

A tool is a controller action marked [McpTool]. The API is the only definition of each tool:

  • Name and title come from the attribute: [McpTool("Projects_GetProject", "Get project")]. A tool name is a contract with the skills and prompts that already call it, so never rename one.
  • Description is the action's [EndpointSummary] followed by its [EndpointDescription]. Write it for an agent choosing between tools; it is also the endpoint's published documentation.
  • Input schema comes from the action's operation in the runtime OpenAPI document: one argument per path, query or header parameter, plus requestBody for a JSON body (a JSON Patch body is sent as application/json-patch+json, the only type a patch endpoint accepts). Parameter and property descriptions are the <param> and /// documentation the OpenAPI document already publishes, and closed sets arrive as enum codes.
  • Output schema comes from the operation's first success response with a JSON body, converted the same way but without request validation rules such as minLength and pattern, which the API does not hold its responses to, and a call answers with that body as structuredContent beside the same JSON as text. The schema is always an object, which protocol versions before 2026-07-28 require: a response that is anything else, an array or a created id, sits under result. A tool whose endpoint answers 204 has no output schema, and a tool with one that gets no body it describes, such as a 204 for a missing record, answers with an error rather than bare text. In both schemas, a type that inherits (allOf a base type and its own part) is combined into one, and a type with subtypes ([JsonDerivedType]) becomes anyOf its subtypes, each with its $type. Clients may reject a result its schema does not accept, so a response model the OpenAPI document describes wrongly breaks its tool; McpEndpointTests calls every read-only tool that needs no arguments and validates each answer.
  • Result size is capped at ApiToolResult.MaxResultLength characters (about 20,000 tokens), because most list endpoints return every matching record. A longer answer has items dropped from the end of its lists until it fits: the body itself when it is an array, otherwise the arrays directly on it, largest first, so a summary beside a list survives whole. A second text block then says what was kept and how to ask for the rest; it stays out of structuredContent, which must still match the output schema. Nested lists are not cut, so a tree loses whole top-level nodes. A result that is a list keeps at least one item even when that item alone is over the limit, and a single record with no list is returned whole.
  • offset reaches the rest of a cut list. The catalogue adds it to every read tool whose result is an array, unless the endpoint already pages or limits its own answer (page, pageNumber, pageSize, take, top, limit): an offset there would slice only the rows the endpoint chose to return. A whole number sent as 5.0 or as text is accepted, as other arguments are. The tool reads it itself and never sends it: the endpoint still returns the whole list, the tool skips offset items and cuts what remains, always keeping one item so each call moves forward, and the note gives the offset that continues (Call again with offset=74). Slicing is only consistent while the endpoint's order is, so a list endpoint orders its query by a unique key; one that returns rows in whatever order the database chooses can repeat or skip items between calls. A result object holding a list (a record and its children, a rollup and its items) gets no offset, and its list is only cut.
  • Toolset and skill come from [McpTools(McpToolset.Ppm, "wayd-ppm")] on the controller, and every controller with a tool needs one; the catalogue throws on first use otherwise, and when the skill is not a folder in skills/. Toolset names are part of client configurations, so never rename one. The skill is named per controller because toolsets and skills do not line up (planning spans wayd-pi, wayd-roadmaps and wayd-story-maps): name the skill whose description lists the controller's tool prefix, which McpToolCatalogTests checks.
  • Annotations default from the HTTP method, as the npm package's did: only GET is read-only, every write starts destructive, PUT and DELETE are idempotent, and every tool is openWorldHint: false. Set ReadOnly, Destructive or Idempotent on the attribute only to depart from that. A write is advertised as safe (Destructive = false) only when it purely adds a record.

A call runs through the endpoint itself. ApiPipeline sends the arguments as an HTTP request through the API's own middleware pipeline, in process, carrying the caller's credentials, or for an OAuth caller the delegated token described above. Authentication, [MustHavePermission], feature gates, model validation, the controller's own checks and the domain's delivery-leadership rules all apply exactly as they do to the same HTTP request, and a refusal comes back as a tool error naming the status and the reason.

The two import tools, Imports_GetFileFormat and Imports_Preflight, front every import endpoint at once, so they are written by hand in ImportTools, in the imports toolset.

Skills are served from skills/, two ways. The build embeds every .md under the repository's skills/ in Wayd.Web.Api, and Skills reads one skill per folder: named after the folder, titled by its SKILL.md heading, described by that file's frontmatter description, with every other .md in the folder as its reference files. To change what either way serves, edit the skill; SkillsTests holds both to the files.

  • As prompts (SkillPrompt), one per skill, with no arguments. Its messages are SKILL.md without the frontmatter, then each reference file in path order. Clients show prompts as commands a user picks, so a model does not reach for one unprompted.
  • As the Skills_Get tool (SkillTools), which a model does call on its own. It takes a skill's name and, optionally, a reference file by the path SKILL.md links it with, and answers with that markdown as text. It declares no output schema: with one, the spec expects the text to be the structured content serialized, so a client would hand the model escaped JSON or the skill twice. McpToolCatalogTests exempts it from the rule that read tools declare one. Its description lists each skill with the first sentence of its description, which names the tool prefixes the skill covers.

Every connection gets every prompt and Skills_Get, whatever toolsets or read-only mode it chose: the guidance applies to whichever tools it has. An agent is pointed to the tool three ways: the server instructions say to fetch an area's skill before working in it; every other tool's description ends with Guidance for this area: Skills_Get with name "wayd-ppm".; and a failed call carries a second text block naming the skill, both when the tool refuses its arguments and when the API answers a 4xx other than 401 and 429. Nothing makes the agent read a skill first: the server is stateless and cannot know what an agent has read, and requiring it would stand in the way of agents that already know the area. A workflow an agent must find on its own also carries a compact recipe in the description of the tool that ends it (Projects_CreateProjectHealthCheck, PlanningIntervals_CreateObjectiveHealthCheck), and the server instructions name those workflows.

A connection chooses its tools. McpToolFilter reads the toolsets (toolsets query parameter, else X-MCP-Toolsets) and read-only mode (readonly, else X-MCP-Readonly) from each request, refusing an unknown name with 400. The server is stateless, so the filter is applied to every request, tools/call included: a tool outside the selection cannot be called, and read-only mode, which keeps only tools annotated readOnlyHint, is enforced rather than advisory. A write tool wrongly annotated read-only would therefore leak into read-only connections, which is one more reason to leave annotations to the HTTP method.

While the npm package is still published, the hosted endpoint must offer every one of its tools. Wayd.Web.Api.IntegrationTests/Mcp/npm-tool-parity.json records each npm tool's name, title, annotations and arguments, plus the server instructions, which the hosted server sends as McpServerInstructions.Shared followed by how to fetch a skill, a tool the package does not have. The package's tests/hosted-parity.test.ts keeps it current (UPDATE_PARITY_SNAPSHOT=1 rewrites it), and McpToolCatalogTests holds the API to it. A tool added to the npm package must be added to the API; the hosted endpoint may offer more, which is where composite tools live.

The npm package​

The @wayd/mcp package in Wayd.Web/src/Wayd.Mcp exposes the Wayd API as agent tools. It is published to npm separately from the application, so a deployed API and the MCP clients that call it can drift apart. For connecting a client to it, see MCP Server.

Every shipped change needs a changeset​

The package is released with changesets. A change merged to main without a changeset is never released: the release PR only picks up pending changesets. Nothing else fails, so a renamed or newly added tool can sit unreleased while the API it calls moves on. Clients on the published version then send fields the API no longer reads, or miss fields it now needs.

A change needs a changeset when it touches what the package ships:

  • src/ — tool definitions, the executor, the server
  • scripts/ — the generators that produce src/generated/ at build time
  • README.md and package.json
  • the API's OpenAPI document (Wayd.Web.Api/wwwroot/api/v1/specification.json), when it changes an import endpoint. The import file formats are generated from it, so changing an import row class changes the package even when no MCP file changes.

Add one from Wayd.Web/src/Wayd.Mcp:

npx changeset # choose patch / minor / major and describe the change
npx changeset --empty # the change ships nothing worth releasing, on purpose

The package is pre-1.0, so a breaking change to a tool's inputs (a renamed or removed field) is a minor bump, and fixes are patch. Write the summary for the people calling the tools: name the tools and fields that changed, and what a caller sends now.

How it is enforced​

.github/scripts/mcp-changeset-check.sh fails when a branch changes any of the above without adding a changeset. A spec change counts only when regenerating the import formats from it changes them, so most API changes are unaffected.

  • CI runs it as the changeset job of the MCP workflow on every pull request that touches the package or the spec. The release PR (changeset-release/main) is exempt, since it consumes the changesets.
  • The pre-push hook runs it before every push, so you see it before the pull request does. It is enabled with the other hooks: git config core.hooksPath .githooks, once per clone. It skips itself, with a note, until npm ci has run in Wayd.Web/src/Wayd.Mcp. Bypass it with git push --no-verify.

Run it by hand against any base:

bash .github/scripts/mcp-changeset-check.sh origin/main

Release flow​

  1. A pull request merges to main with its changeset.
  2. The MCP workflow opens or updates the release PR, which bumps the version and writes CHANGELOG.md.
  3. Merging the release PR publishes the package to npm and tags it wayd-mcp-v<version>.

Skill layout​

Each skill in skills/ is a folder whose SKILL.md is the overview an agent loads when the skill triggers: the frontmatter name and description, the rules that apply to every call in its area, and a Reference table naming each file in its reference/ folder and when to read it. Detail that only one workflow or area needs lives in reference/*.md, one level deep and linked from SKILL.md by relative path, so an agent reads it only when the task calls for it. Keep SKILL.md well under 500 lines.

Skill evaluation​

The skills in skills/ are tested by scenarios in Wayd.Web/src/Wayd.Mcp/evals/: a user request, canned API responses, and the tool calls a correct run makes. Each runs through Claude Code against a stub of the MCP server, with and without the skills, on each model, and is graded on its tool calls. Run it before and after changing a skill, and add a scenario when a skill gains guidance worth testing. npm test checks that every scenario is well-formed; the evaluation itself is run by hand, because it uses a Claude Code plan. See the evaluation README.