@webpieces/mcp-server
v0.4.977
Published
Secure MCP tool generation from annotated Webpieces API contracts
Readme
@webpieces/mcp-server
Publishes explicitly annotated Webpieces RPC endpoints as MCP tools. The bridge is HANDED the input
and output JSON Schema — the build extracts them from the contract source and writes one
mcp-<ContractClass>-tools.json per contract — invokes the normal Webpieces filter/controller path, and turns exceptions into safe model-visible
MCP results.
@WpMcpTool opts an endpoint into the protocol. Every endpoint declares canonical
@WpAuth credentials and common @WpAuthorization policy. The same policy governs tool visibility,
schema lookup, dispatch and receiving endpoint execution. The MCP adapter never accepts trusted
context from tool arguments or unverified headers.
interface FindOrderRequest {
/** Order identifier */
orderId: string;
}
interface FindOrderResponse {
/** Current order state */
state: string;
}
@ApiPath('/orders')
@ApiType(SVC_TO_SVC, MCP)
abstract class OrdersApi {
/**
* Find one order owned by the signed-in user.
*
* @summary Find orders
* @returns The order, with its current state.
*/
@WpAuth([jwt()])
@WpAuthorization({ authType: AuthorizationType.ALL_USERS })
@Endpoint(POST, '/find', READ, RPC)
@WpMcpTool('orders_find')
find(request: FindOrderRequest): Promise<FindOrderResponse> {
throw new Error('contract only');
}
}Everything but the tool NAME comes from the source
@WpMcpTool(name) takes ONE argument (plus the optional profiles object), because the stable protocol
name is the only fact the source cannot state. It must be 1-64 characters of [A-Za-z0-9_-] — the
Claude Connectors Directory refuses a server with a longer name — and McpToolName enforces that rule
at decoration time, in wp-openapi (every offender named in one run), in api-rules-for-mcp, and when a
catalog is loaded.
The TITLE is the method's @summary JSDoc tag (#1296): one line, at most 100 characters, REQUIRED on
every published operation. It is the same line the OpenAPI documents publish as the operation's
summary, so a partner's docs page and an agent's tool list name the endpoint identically, and
tools/list publishes it as BOTH the top-level Tool.title and annotations.title (#1180). The title
argument the decorator used to take is gone — passing one is a compile error — and a catalog entry
with no title is refused when it is loaded, so a stale catalog fails the boot instead of publishing
untitled tools. Write the title a person would read ("Find orders"); never derive it from the name.
Everything else is read off the contract by @webpieces/api-doc-model at BUILD time:
| fact | source |
|---|---|
| title / annotations.title | the method's @summary JSDoc tag — the OpenAPI operation's summary |
| description | the method's JSDoc body, or its @mcp tag — the same words the partner reads in OpenAPI |
| inputSchema / outputSchema | the declared request and response types, with each field's JSDoc as its description; inputSchema.description is the request DTO's JSDoc and outputSchema.description is the method's @returns — the sentence OpenAPI publishes as the success response's description (#1292) |
| readOnlyHint / destructiveHint / idempotentHint | @Endpoint's operation — READ, WRITE_IDEMPOTENT or WRITE |
| openWorldHint | @Endpoint's openWorld option |
| x-mcp-header | the field's @mcpHeader <token> JSDoc tag |
No @privateRemarks text reaches any of it: the generator drops the tag at extraction, and it refuses
a tool whose method has no @returns or that reaches a schema with no JSDoc description, exactly as it
refuses them for OpenAPI (#1292). The server serves the catalog's descriptions verbatim —
McpServedDescriptions.integration.spec.ts boots it on the partner-api golden catalog and reads them
back through tools/list.
Every @Endpoint declares one enum-backed operation, independent of its required HTTP verb
argument: a GET may deliberately write and a POST may be a read.
A method that binds any argument to the URL (@PathParam / @QueryParam) is not an MCP tool: a
tool receives exactly one input object, so McpToolRegistry refuses @WpMcpTool on such a method
at boot. A tool therefore cannot claim to be
read-only while its endpoint declares WRITE — the contradiction is unrepresentable.
Booting: the server is constructed with the generated catalogs
The api library's build (its openapi-generate target — tag the library generate:openapi) writes one
mcp-<ContractClass>-tools.json per MCP contract into its build output, beside the OpenAPI documents.
Hand them to McpBindOptions with ONE call that works in every environment:
const catalogs = McpToolCatalog.fromPackages(['@myorg/my-apis'], __dirname);
server.bind(app, new McpBindOptions(path, bindings, catalogs, McpDeployment.singleProcess()));fromPackages resolves each package from __dirname the way node does. A BUILT or published package
(a Docker image relinking node_modules/@myorg/* onto dist/) holds the files beside its
package.json; a workspace SOURCE directory (pnpm linking onto libraries/... for local dev and
vitest) holds a project.json, and the files are read from the outputPath of the target its
openapi-generate dependsOn (its build) — which is why nx.json's targetDefaults for a server's test
(and build) must dependsOn ^openapi-generate; validate-nx-wiring prints the exact line.
McpToolRegistry FAILS FAST at boot, naming every directory the catalogs were read from, when a bound
contract has no catalog, a catalog's contract is not bound, or a tool name is declared twice — and when
a registered @WpMcpTool is absent from its contract's catalog: a tool the build never saw is a tool
whose schema nobody checked. Tool arguments and structured output are
validated against those same schemas by ApiJsonSchemaValidator, so the shape an agent is shown and
the shape the server accepts are the same bytes rather than two derivations of one contract.
Before #984 the schemas were instead built at boot from @WpDtoField reflect-metadata. That spelling,
@WpDto, WpDtoFieldOptions, WpDtoMapFieldOptions, WpMcpHeader, @WpResponseDto and
DtoSchemaBuilder are all DELETED — see the migration note in responsibilities.md.
Typed maps
A Record<string, V> is read straight off the declared type and emits
{ type: 'object', additionalProperties: <value schema> }. Validation checks every value
($.translations.es must be a string). A map is still a CLOSED schema: no key can carry an unspecified
value, and ApiJsonSchema.isClosed(schema) answers that for both forms — use it rather than
schema.additionalProperties === false, which wrongly rejects typed maps.
Nullable
externalId: string | null publishes as type: ["string", "null"], which is a different wire document
from omitting the key. OPTIONAL (externalId?: string) is the absence of the field from required.
The two are kept apart.
Configuration
WpMcpServerConfig is built with fluent setters — every name sits next to its own value, so no two
settings can be swapped, and a future setting is an additive setter rather than a breaking signature
change. Expect to supply the type arguments explicitly:
const config = new WpMcpServerConfig<MyGrant>()
.setName('my-server')
.setTitle('My Product')
.setDescription('Search and manage My Product accounts from any MCP client.')
.setWebsiteUrl('https://www.example.com/my-product')
.setVersion('1.0.0')
.setResource('https://api.example.com/mcp')
.setAccessTokenAuthority(authority)
.setAuthorizationService(router.authorizationService())
.setAuthorizationServers(['https://login.example.com'])
.setRequiredScopes(['tools'])
.setMaxAccountValidationAgeSeconds(15 * 60);
// The tools/call error translator is a PROCESS-GLOBAL, not a member of this config:
McpRegistry.setErrorTranslator(new MyMcpErrorTranslator());Each setter validates its own value immediately and names itself in the failure: setResource(...)
and every entry of setAuthorizationServers(...) must be an absolute URL, because they are compared
against a token's audience and issuer. setMaxAccountValidationAgeSeconds defaults to one hour and is capped there.
setTitle, setDescription and setWebsiteUrl are REQUIRED and are sent on initialize's
serverInfo (and on every modern-era result's io.modelcontextprotocol/serverInfo meta) as
title, description and websiteUrl:
| setter | serverInfo field | validation |
|---|---|---|
| setTitle(title) | title (MCP 2025-06-18) | one line, at most 100 characters |
| setDescription(description) | description (MCP 2025-11-25) | non-empty, at most 2000 characters |
| setWebsiteUrl(url) | websiteUrl (MCP 2025-11-25) | absolute https:// URL |
The limits are the Claude Connectors Directory's listing Name and Description limits. The app sets all
three because the server cannot derive them: it does not read the OpenAPI manifest at runtime, and one
server may bind contracts from several api libraries. websiteUrl is a page a human reads (the product
homepage or docs site), so it is never derived from setResource — that is the MCP endpoint, not a
website. There are no defaults; a missing one fails bind(...) naming the setter, like setName.
setIcons(...) and setToolProfiles(...) are the only optional non-ceiling settings (the error
translator is process-global, on McpRegistry); everything else is required and
WpMcpServer.bind(...) fails at startup listing every missing setter
(WpMcpServerConfig is missing setResource(...)). That is deliberate: a misconfigured audience or
issuer otherwise surfaces as a first-request 401 + WWW-Authenticate, which is the OAuth discovery
signal, so a client answers it by re-authenticating and failing again — forever.
Applications supply the endpoint path, explicit bindings, and deployment topology. There is no
framework-owned /mcp path:
const mcp = new WpMcpServer(config);
mcp.bind(expressApp, new McpBindOptions(
connectorConfig.endpointPath,
[
McpApiBinding.local(OrdersApi, apiFactory),
McpApiBinding.remote(RemoteOrdersApi, () => remoteOrdersClient),
],
McpDeployment.singleProcess(),
));bind(...) registers POST at that path plus a 405 (Allow: POST) for every other method there —
neither served era has a session GET here (2026-07-28 has none at all, and the 2025 leg is the SDK's
STATELESS fallback, which answers the 2025 session GET/DELETE with the same 405), so a probe gets a
method error rather than a 404. It may be called only
once: one server serves exactly one canonical resource URI. WpMcpServerConfig.resource is that URI,
fixed for the process lifetime and never derived from the request Host (a host-derived audience would
make the boundary's own audience check circular — a confused deputy), and its path must equal
endpointPath, which bind(...) verifies. A second hostname is a second deployment, not a second
audience.
Protocol revisions: negotiated, never pinned
One endpoint serves BOTH MCP wire eras from one tool registry — modern (2026-07-28, per-request
_meta envelope) and legacy (the 2025-11-25 family, negotiated by initialize). The official SDK
classifies each request and answers it on the matching leg, calling the SAME server factory for both,
so the two eras can never drift apart. There is no configuration for this and no way to turn it off:
a revision an app can pin is a revision an app can pin itself out of reach with, which is exactly what
happened when this bridge was modern-only and every shipping client (all still on the 2025
generation) got -32022 Unsupported protocol version at initialize. Only a revision NO era knows is
refused, which is what the MCP lifecycle spec asks of a server.
The external bearer is verified identically on both legs, at the bind(...) HTTP boundary, before the
SDK is involved — the era is the SDK's decision, the authentication is not.
The official MCP v2 server and Node adapter own JSON-RPC validation for both eras, JSON versus
request-scoped SSE responses, backpressure, cancellation, keepalives, and subscriptions. Webpieces
owns the Express path, root RequestContext, authentication boundary, schema-derived tool registry,
and dispatch through the normal API proxy.
During a tool invocation, RequestContext.getTrusted(MCP_INVOCATION_CONTEXT) exposes the verified
JSON-RPC ID, tool/user/role facts, the official SDK cancellation AbortSignal, and (when the client
supplied _meta.progressToken) a reportProgress(...) callback. Progress remains related to that
one request and causes the official handler to select request-scoped SSE; closing that response
aborts the same signal. The context key has no HTTP header and is never propagated as caller input.
Local bindings require explicit tool exposure and valid common authorization. They pass a token-free
InvocationAuthentication through ApiFactory.createInvocationApiClient, preserving the ordinary
logging, authentication and authorization filters. The proof identifies the actual endpoint and
original verified ingress scope. No endpoint JWT is minted or parsed and no GUI JWT_HOOK is required.
Remote bindings declare oidcSameProject(), oidcSvcs(...) or sharedSecret(...) network credentials. The generated Node
client chooses its service mechanism before forwarding verified delegated facts; the MCP bearer never
becomes a downstream credential.
McpDeployment.singleProcess() uses an in-memory invalidation bus and makes that limit explicit.
Distributed deployments must use McpDeployment.distributed(sharedBus). List responses have a bounded
TTL, registry metadata contains a surface-derived revision, toolsChanged() publishes a
level-triggered invalidation, and close() drains active response streams. A reconnecting client must
re-listen and refresh its authoritative lists; notifications are not a durable or replayable event log.
The authority verifies issuer, signature/token state, expiry, scopes, the exact resource URI, and
current account state. Neither served era keeps a session here, so the bearer is verified once per POST
at the bind(...) HTTP boundary, before the SDK is involved; tool handlers never re-verify it. A
rejected token must be thrown as ApiUnauthorizedError (answered 401 + WWW-Authenticate); any
other throw from the authority is an implementation failure (500). MCP access tokens are capped at 30 days.
Principal, policy and request context
VerifiedMcpCredential.caller is the application's AuthenticatedCaller resolved from current account
facts. principalRoles is derived from that caller, a public hard cut from the old listing-role field.
The framework publishes its canonical USER_ID and JSON-array USER_ROLES; custom trusted facts come from the verified caller entries. Security metadata
(issuer, exact resource, scopes, issuance/expiry and account validation time) remains on the credential.
The framework publishes the caller's trusted entries and llm surface immediately after verification,
then reconciles inbound trusted headers using the same operation as ordinary HTTP. Contradictory or
unvouched identity/surface headers are rejected. The common AuthorizationService governs the request's authorized
tool view before SDK processing; only explicit ApiForbiddenError denials hide tools. Policy/datastore
failures propagate to the safe ingress boundary. Documentation, synchronous SDK schema lookup and call
name resolution all use that view. The dispatcher independently checks the same policy before input
validation and invocation. A hidden tool is indistinguishable from an unknown name and triggers no
schema validation or binding invocation.
Each HTTP POST has one transport-owned RequestContext.run, including malformed-body failures. Local
invocation opens no detached/nested scope and retains the original transport request, request/action IDs,
custom context and downstream response-header accumulation. Its immutable InvocationAuthentication
is carried on that invocation's proxy/MethodMeta, never on a mutable ambient credential slot. Normal
receiving endpoint authorization runs against the same canonical trusted facts. A captured/restored
snapshot cannot reuse the local proof. GUI HTTP requests verify their own bearer normally. Remote
bindings authenticate their own hop before accepting delegated facts; another HTTP process has its
own scope. SERVICE_ONLY requires actual current-hop machine evidence, even when the surface is llm.
See the authorization migration and release order.
Error boundary
WpMcpErrorTranslator is the one place a failure becomes an MCP reply, mirroring
WebpiecesDefaultErrorTranslator: an error in, the exact wire shape out. Each entry point has exactly one catch
that delegates to it. HTTP ingress uses one translating wrapper and all SDK callbacks use one shared catch implementation with explicit protocol/tool stage; the dispatcher and invokers have none. Classification is
the shared ApiErrorBoundary.encode rule (a non-ApiError, or a caller-local ApiConnectionError,
publishes as kind implementation). The boundary never substitutes an object for the thrown error,
so an app's McpErrorTranslator can instanceof its own error classes.
There is ONE method per BOUNDARY, named for the boundary it guards. They cannot be one shape — the MCP spec fixes each. The shared SDK catch selects protocol versus tool rendering by operation/stage:
| Boundary | Method | Reply |
|---|---|---|
| before the SDK: bearer, Origin, malformed body | toBearerBoundaryResponse(error): HttpResponseDto<McpHttpErrorBody> \| HttpResponseDto<OAuthErrorBody> | HTTP 401 + WWW-Authenticate / 403 / 400, anything else 500; an OAuthProtocolError gets the RFC 6749/6750 body at its own status |
| tools/list | toListError(error): never | throws JSON-RPC -32602 (bad request) or -32603 Internal Error |
| tools/call, tool found: bad arguments, endpoint policy denial, JWT/OIDC mint failure, any local or remote failure, output-schema or serialization failure | toToolCallResult(error): CallToolResult (delegates to McpRegistry.getErrorTranslator().toWire) | isError: true result |
| tools/call, unknown or hidden tool name (including a dispatcher policy recheck denial) | unknownTool(name) | JSON-RPC -32602 Unknown tool: <name> |
The pre-SDK boundary produces a VALUE like every other API in the framework and WpMcpServer writes
it through the same ExpressResponseWriter the ordinary HTTP path uses — it does not hand-roll
res.status(...).json(...). The body stays JSON-RPC shaped because an MCP client expects that — except for an OAuthProtocolError
(an authorization service refusing the bearer with invalid_token / insufficient_scope), which is
answered in the RFC shape by OAuthErrorResponse, keeping this server's challenge on a 401;
HttpResponseDto is generic and its header LIST carries WWW-Authenticate.
toListError is typed never — it THROWS rather than returning a value the caller must remember to
throw — and it is LOAD-BEARING for disclosure, not defence in depth: the official SDK wraps a foreign
throw in -32603 but copies its message onto the wire verbatim (measured and pinned by
McpSdkForeignThrow.spec.ts).
Operator logging is NOT in the translator. The edges with no filter chain above them — bearer
verification, Origin, a malformed body, tools/list — are wrapped in LogApiCall, which is this
repo's one line on logging; tools/call dispatches through the ordinary filter chain, so
LogApiFilter owns its line. Exactly one [API-server-resp-FAIL] / -OTHER line per failure, with
the request, the identity and the timing on it, and none of the barer second line
ApiErrorBoundary.logOperatorDetail used to add.
The one addition is for failures that are THIS server's bug. Before tools/list or tools/call
hands an implementation-kind failure to the translator, WpMcpServer writes one log.error with
the error's stack and requestId=<id> — the same id the caller is told to give support (#1055). An
MCP output-schema violation needs it most: it is raised after the controller returned, so the only
LogApiCall line for that call reads resp-SUCCESS. Caller-kind failures (bad-request, end-user,
dependency, ...) get no such line.
subscriptions/listen is served entirely by the SDK's listen router, so Webpieces has no handler there.
requestId comes from RequestContext and the JSON-RPC id and tool name from the request scope,
so none of the three methods takes a correlation parameter — the deleted McpFailureScope was a copy
of context the renderers can already read.
The default content[0].text is JSON with an explicit { "error": { ... } } envelope, because some
clients display only content and hide the top-level MCP isError. The nested error carries the
precise kind, a small ownership category, explicit retry guidance, a safe message, and the
requestId. It may also carry safe fields such as field, callerMessage, errorCode,
retryAfterSeconds, and statusCode. It never repeats isError inside the content.
MCP keeps no retry table of its own: the advice is a function of the ONE outcome
ApiRegistry.classify gives every reader (RetryStrategy, LogApiCall), the endpoint's
operation, and retryAfterSeconds. Implementation failures are category: "bug", retry: "never" and
tell the caller to give the request ID to support; operator details remain redacted. Generic
dependency failures are also non-retryable, and so is retries-exhausted (category: "dependency",
retry: "never"): this service already retried that dependency, so the model must not retry it again.
A CallOutcome.TRANSIENT_FAILURE — gateway, timeout, unavailable, throttling, backoff, or anything an
app's classifier marks transient — is temporary, but a retry is described as safe only for a read
or write-idempotent endpoint. A write reports retry: "unsafe-outcome-unknown", because a lost
response does not prove the write failed to commit.
Every reply carries the requestId so a user can quote it: _meta["webpieces/requestId"] on every
tools/call result (success or isError), requestId in the isError payload, and
error.data.requestId on JSON-RPC and HTTP-boundary errors.
The application's own tools/call translation
An app owns its error taxonomy and owns how those errors should be explained to a model, so a
registered translator REPLACES the webpieces default for every tools/call failure — the same
convention ExpressWrapper.handleError applies on the HTTP path. Register one with
McpRegistry.setErrorTranslator(...): ONE process-global per protocol, mirroring ApiRegistry
for HTTP and IpcRegistry for IPC, so every call site is one unconditional line. (Two MCP servers in
one process therefore share one translator, and registration order matters rather than being fixed at
construction — the trade taken deliberately in issue #968 to make all three protocols read alike.)
class LangMcpErrorTranslator implements McpErrorTranslator {
private readonly fallback = new McpDefaultToolCallRenderer();
toWire(error: Error): CallToolResult {
if (!(error instanceof LangPassageLockedError)) {
return this.fallback.toWire(error); // not mine -> webpieces default
}
return {
content: [{ type: 'text', text: `Passage ${error.passageId} is locked.` }],
structuredContent: { passageId: error.passageId, action: 'ask_the_user_to_unlock' },
isError: true,
};
}
}erroris the thrown value itself — webpieces never substitutes another object for it, soinstanceofon the app's own classes works.- There is no "not mine" return. Decline by DELEGATING to
McpDefaultToolCallRenderer, the public class that IS the webpieces default, so declining is byte-identical to registering nothing. - A claimed error owns the ENTIRE
CallToolResult— content,structuredContent,isError. - webpieces default-fills
_meta["webpieces/requestId"]only when the returned result has no_meta; an app that sets_metakeeps it untouched. - The failure has already been logged by the filter chain when the translator is called, so claiming an error can never silently kill observability. A translator that THROWS is reported on its own line and the original error still renders the reply.
- The scope is
tools/callONLY.tools/listand the pre-SDK HTTP boundary stay framework-owned: that boundary emits the401 + WWW-Authenticate: Bearer resource_metadata=...MCP clients depend on for OAuth discovery, and an app rewriting it breaks connector onboarding in a way that is extremely hard to debug.
A remote binding's generated client preserves typed dependency gateway, unavailable, timeout, and
backoff failures. Foreign/bodyless responses are classified by status: 408/504 timeout, 429 backoff,
502 bad gateway, and 503 unavailable (or backoff when Retry-After is present). Other 4xx remains
the caller service's ApiImplementationError; other 5xx remains generic ApiDependencyError.
Why there is no fromWire on McpErrorTranslator
The HTTP and IPC translators have two halves because webpieces sits on both ends of those wires. webpieces is never the MCP client — Claude is — so there is no return path for webpieces to translate. The pair is one-directional by nature, not by omission.
The verifier's accountValidatedAtEpochSeconds must represent an authoritative enabled/revoked and
role/scope read. The bridge enforces a maximum one-hour decision age; per-request reads are preferred so
offboarding and role changes take effect on the next call. JWT access-token implementations must enforce
an explicit algorithm allowlist plus issuer, exact audience/resource, expiry, type/version, and key
rotation metadata. Opaque tokens remain valid implementations of the same authority contract.
protectedResourceMetadata() returns the resource metadata an HTTP adapter can publish at the
well-known OAuth protected-resource endpoint. OAuth token issuance remains pluggable.
Refusing in the RFC shape from your own OAuth routes
Token issuance, client registration and the authorization endpoint are the app's own express routes.
Refuse there with OAuthProtocolError (from @webpieces/core-util) and mount
OAuthExpressErrorHandler (from @webpieces/http-server) as the error handler, so the OAuth client
gets { "error": "invalid_grant", "error_description": "..." } at the RFC status with the right
WWW-Authenticate, and the refusal is logged at INFO rather than as a server ERROR:
const oauthErrors = new OAuthExpressErrorHandler();
app.post('/oauth/token', urlencoded({ extended: false }), async (req, res) => {
if (req.body.grant_type !== 'authorization_code') {
throw new OAuthProtocolError(OAuthErrorCode.UNSUPPORTED_GRANT_TYPE, 'only authorization_code');
}
// ...
}, oauthErrors.middleware);
// authorization endpoint: errors go back as a redirect, never a status (RFC 6749 §4.1.2.1)
res.redirect(302, new OAuthAuthorizationErrorRedirect(
validatedRedirectUri, OAuthErrorCode.ACCESS_DENIED,
typeof req.query.state === 'string' ? req.query.state : undefined, 'The user declined').toUrl());An authorization service that throws an OAuthProtocolError (invalid_token, insufficient_scope)
while this server verifies a bearer is rendered the same way at the MCP boundary, and a 401 without
its own challenge keeps this server's resource_metadata challenge. The status is derived from the
code; only invalid_client may choose 400 or 401, and the constructor's overloads make any other
status a compile error. See docs/portable-ipc-and-errors.md for the full vocabulary.
That endpoint's URL is WpMcpServerConfig.resourceMetadataUrl — RFC 9728 §3.1 path insertion, so
https://host/mcp publishes at https://host/.well-known/oauth-protected-resource/mcp — and it is the
value of resource_metadata in the 401 WWW-Authenticate challenge. RFC 9728 §5.1 defines that
parameter as the URL of the metadata DOCUMENT, never the resource identifier itself. The framework
mounts no .well-known route; the app publishes the document there.
Tool profiles and independent endpoints
Profile membership partitions tool exposure; account roles still belong exclusively to
@WpAuthorization. Selecting an admin profile never grants admin authority. Import Mcp and
WpMcpTool from the browser-safe @webpieces/core-util package:
@WpMcpTool('learner_get_passages') // only Mcp.DEFAULT
@WpMcpTool('admin_list_users', { profiles: ['admin'] }) // only admin
@WpMcpTool('shared_tool', { profiles: [Mcp.DEFAULT, 'admin'] })Each declaration also needs the usual endpoint, DTO, authentication, and authorization declarations,
and a @summary line in the method's JSDoc for its title.
One argument, an omitted profiles property, and explicit [Mcp.DEFAULT] all select only the base
group. An explicit list replaces that membership; it never implicitly adds the base group.
Identifiers match [a-z][a-z0-9-]{0,63} (1–64 lowercase ASCII letters, digits or hyphens, starting
with a letter). Mcp.DEFAULT reserves default for the base group. Empty lists, duplicates, unknown
option keys, whitespace, paths, and invalid identifiers are rejected. Lists are snapshotted and sorted.
Configure one independent WpMcpServer per mount with the same bindings, complete catalogs, and
receiving router's AuthorizationService. Start from the required fluent configuration shown above:
baseConfig.setResource('https://api.example.com/mcp').setToolProfiles([Mcp.DEFAULT]);
adminConfig.setResource('https://api.example.com/mcp/admin').setToolProfiles(['admin']);
bothConfig.setResource('https://api.example.com/mcp/both').setToolProfiles([Mcp.DEFAULT, 'admin']);Bind each instance with McpBindOptions.endpointPath equal to its resource path: /mcp,
/mcp/admin, or /mcp/both. Omitting setToolProfiles selects [Mcp.DEFAULT].
A combined selection is the union, with shared tools listed once in stable name order. Further groups
such as course-authoring work identically. No caller-controlled profile route or hint exists.
Configure instances before binding; the selected groups are fixed when bound.
Publish each instance's protectedResourceMetadata() at its configuration's resourceMetadataUrl,
e.g. /.well-known/oauth-protected-resource/mcp/admin. Token authorities must verify the fixed exact
resource, trusted issuer, expiry, scopes, and fresh account state. A /mcp token is rejected on
/mcp/admin. Never derive audiences from Host headers. OAuth consent, grants, and connector setup
remain application-owned; framework tests do not establish Claude.ai's simultaneous credential behavior.
All supplied contracts/catalogs are validated before profile projection, including excluded entries.
Discovery, schema lookup, and execution share the same profile-and-authorization view. Membership
is catalog metadata, never a nonstandard MCP Tool field. Catalog entries now require profiles;
artifacts from before this change are rejected with a cure to rebuild the contract library's
openapi-generate target. Regenerate catalogs when upgrading; there is no schema reflection fallback.
Product icons
Configure optional standard MCP implementation icons through the fluent config:
import { McpIcon, WpMcpServerConfig } from '@webpieces/mcp-server';
const config = new WpMcpServerConfig<MyGrant>()
.setName('my-product')
.setTitle('My Product')
.setDescription('Search and manage My Product accounts from any MCP client.')
.setWebsiteUrl('https://www.example.com/my-product')
.setVersion('1.0.0')
.setResource('https://api.example.com/mcp')
.setIcons([
new McpIcon(
'https://api.example.com/branding/product-v1-128.png',
'image/png',
['128x128'],
'light',
),
]);
// Supply the remaining required authority, authorization and scope setters as usual.setIcons accepts standard SDK Icon descriptors (src, optional mimeType, sizes,
and theme) as well as McpIcon instances. Multiple descriptors support resolutions and
light/dark backgrounds. Sizes use positive widthxheight values or any. The configuration
copies and freezes descriptors, including size arrays; binding captures that snapshot, so later
caller mutation or configuration setters cannot change a running bridge's icons. Omitting icons
leaves the metadata absent; an explicitly empty array advertises no icons.
Host the example PNG at a publicly readable HTTPS URL on the MCP resource's own origin. It must require no cookie, bearer header or other credentials, and must not redirect to another origin or scheme. Hosting and image contents are application-owned: this package adds no static asset route and never fetches icons on startup or during requests. HTTPS URLs containing user credentials, other URI schemes and malformed descriptors are rejected. Resource-origin validation runs at bind time, so setter order does not matter. Clients must still enforce safe redirects, image parsing and resource limits when fetching/rendering untrusted image bytes.
Alternatively embed a non-empty base64 image data URI, for example
new McpIcon('data:image/png;base64,...', 'image/png') with actual base64 image bytes in place
of .... PNG is the broadly compatible choice. SVG support is optional for clients and requires
additional sanitization; valid metadata does not guarantee safe image contents.
The SDK renders icons in legacy initialize.result.serverInfo.icons and current
result._meta['io.modelcontextprotocol/serverInfo'].icons (2026-07-28). Advertising icons
does not guarantee every MCP client renders them or invalidate a provider's branding cache.
See the MCP icon specification and security guidance.
