mcp
Model Context Protocol transport for Webda — exposes registered operations as MCP tools and models as MCP resources, over Streamable HTTP and stdio.
When to use it
- You want AI assistants (Claude Code, Claude Desktop, any MCP client) to call your Webda operations as tools.
- You want models readable as MCP resources through
webda://{Model}/{pk...}URIs. - You want the same permissions as your REST API: each caller only sees and runs what their session allows.
What it exposes
- Tools: every non-hidden operation, the tool name being the operation id (for example
Post.Get,Posts.Query,User.Login). - Resources: models with a
{Model}.Getoperation, aswebda://{Model}/{pk...}resource templates (for examplewebda://Post/{slug}). - Progress:
AsyncGeneratoroperations stream their chunks as MCP progress notifications. - Truncated outputs: tool outputs larger than
maxOutputBytesare truncated with a notice and returned withisError: true.
Install
pnpm add @webda/mcp
Configuration
{
"services": {
"mcp": {
"type": "Webda/McpService",
"url": "/mcp", // HTTP endpoint; false disables HTTP (stdio only)
"operations": ["*", "!User.Delete"], // standard OperationsTransport filter
"resources": { "models": ["*"] }, // false disables resources; model ids or "*"
"sessionTimeout": 1800, // seconds of inactivity before an MCP session is evicted
"allowedOrigins": [], // extra Origin values accepted besides the request host
"allowedHosts": ["localhost", "127.0.0.1", "::1"], // Host header hostnames accepted (any port); "*" disables the check
"maxSessions": 1000, // cap on open MCP HTTP sessions; at the cap the least recently used one is closed
"maxOutputBytes": 1048576 // larger tool/resource outputs are truncated with a notice
// "serverName": "my-app", // defaults to the application package name
// "serverVersion": "1.0.0", // defaults to the application package version
// "stdio": { "user": "<userId>" }, // default --user for `webda mcp`
// "authenticator": "myAuthenticator", // service implementing McpAuthenticator; default uses the Webda session
// "listOutOfScopeTools": false // list the tools an OAuth token scope does not cover
}
}
}
| Parameter | Type | Default | Description |
|---|---|---|---|
url | string | false | "/mcp" | HTTP endpoint; false disables HTTP (stdio only) |
operations | string[] | ["*"] | Operation filter, with ! for exclusions |
resources | { models: string[] } | false | { "models": ["*"] } | Models exposed as resources; false disables resources |
sessionTimeout | number | 1800 | Seconds of inactivity before an MCP session is evicted |
allowedOrigins | string[] | [] | Extra Origin values accepted besides the request host |
allowedHosts | string[] | ["localhost", "127.0.0.1", "::1"] | Hostnames accepted on the Host header (any port); "*" disables the check |
maxSessions | number | 1000 | Cap on open HTTP sessions; at the cap a new initialize closes the least recently used session, whose next request gets 404 (clients then re-initialize) |
maxOutputBytes | number | 1048576 | Larger tool/resource outputs are truncated with a notice |
serverName | string | application package name | Server name announced to clients |
serverVersion | string | application package version | Server version announced to clients |
stdio.user | string | none | Default --user for webda mcp |
authenticator | string | none | Name of a service implementing McpAuthenticator; the default uses the Webda session |
listOutOfScopeTools | boolean | false | With McpOAuthAuthenticator, also list the tools the token scope does not cover (see OAuth) |
Remote deployments must set
allowedHosts. The Host header is checked on every HTTP request as DNS-rebinding protection: unless your public hostname(s) are listed (or the value is"*"), every request gets403 Host not allowed.Behind a reverse proxy, the hostname checked is the
X-Forwarded-Hostheader when present, otherwiseHost.X-Forwarded-*headers are only accepted from proxies listed in the HttpServertrustedProxiesparameter (anything else gets400from the HttpServer). A proxy that rewritesHostwithout a trustedX-Forwarded-Hostgets403unless the rewritten hostname is inallowedHosts.
Usage
# stdio: Claude launches the application and acts as the given user
claude mcp add blog -- webda mcp --user <userId>
# HTTP: register the endpoint of a running application
claude mcp add --transport http blog http://localhost:18080/mcp
Use https:// when the HttpServer serves TLS. With autoTls (as in the blog sample) the certificate is self-signed: start the MCP client with NODE_EXTRA_CA_CERTS=<app>/.webda/dev-tls/cert.pem so it trusts it.
webda mcp keeps stdout for protocol frames: from the moment the command starts, log lines and any other stdout output go to stderr, whatever --log-stream says. Lines logged while the application boots (before the command starts) follow --log-stream; its default (auto) already sends them to stderr because MCP clients pipe stdout.
Who is the HTTP caller?
The default SessionAuthenticator uses Webda's cookie session. MCP clients (Claude Code, the SDK HTTP transports) do not keep cookies, so HTTP callers are anonymous in practice: they see and run only the operations anonymous users may call. To act as a user, use stdio (webda mcp --user <userId>), use OAuth so MCP clients act as the consenting user, or plug a custom authenticator service:
import { Service, Session, WebContext, WebdaError } from "@webda/core";
import type { McpAuthenticator } from "@webda/mcp";
// Your own key lookup (a model query, a secret store...)
declare function lookupUserForKey(key: string): Promise<string | undefined>;
/**
* Resolve MCP callers from an API key header
*/
export class ApiKeyAuthenticator extends Service implements McpAuthenticator {
async authenticate(ctx: WebContext): Promise<Session> {
const key = ctx.getHttpContext().getUniqueHeader("x-api-key");
const session = new Session();
if (key === undefined) {
return session; // anonymous
}
const userId = await lookupUserForKey(key);
if (!userId) {
throw new WebdaError.Unauthorized("Invalid API key");
}
session.login(userId, userId);
return session;
}
}
{
"services": {
"apiKeys": { "type": "MyApp/ApiKeyAuthenticator" },
"mcp": { "type": "Webda/McpService", "authenticator": "apiKeys" }
}
}
OAuth
McpOAuthAuthenticator makes the MCP endpoint an OAuth 2.1 protected resource (RFC 9728) for tokens issued by
@webda/oauth. MCP clients discover the authorization server, register themselves, send the user
through consent and then call with a scoped bearer token bound to this endpoint. Step-by-step client setup:
Connecting agents.
{
"services": {
"McpOAuthAuthenticator": {
"type": "Webda/McpOAuthAuthenticator",
"publicUrl": "https://api.example.com"
},
"Mcp": { "type": "Webda/McpService", "authenticator": "McpOAuthAuthenticator" }
}
}
| Parameter (authenticator) | Default | Description |
|---|---|---|
publicUrl | none (request host, development only) | Public origin of the application, including any external path prefix |
authorizationServers | publicUrl or the request origin | Authorization servers advertised in the metadata |
resourceMetadataPath | /.well-known/oauth-protected-resource | Path of the protected resource metadata |
scopesSupported | *, <Group>.*, offline_access | Scopes advertised in the metadata, like the Webda authorization server |
Audience binding. The resource is publicUrl + the McpService url (https://api.example.com/mcp above). It
is registered with the OAuthServer, advertised in the metadata, and every token must carry it as audience (RFC 8707):
an application token (aud webda-access) or a token issued for another resource is refused with 401 and
WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…", so the client signs in again.
publicUrlmust be the URL clients use, including any path prefix added in front of the application (an API Gateway stage such ashttps://abc.execute-api.eu-west-1.amazonaws.com/prod, a reverse proxy mount point...).- Without
publicUrlthe resource is derived from the requestHost(or a trustedX-Forwarded-Host) and a warning is logged at startup. The audience check then follows whatever host the request names: it is not a security boundary, use it for development only. - The metadata is served at
/.well-known/oauth-protected-resource(resourceMetadataPath), with the RFC 9728 path-suffixed form when several MCP endpoints share the authenticator. - Without a token the endpoint answers
401withWWW-Authenticate: Bearer resource_metadata="…", which starts the client sign-in.
Scope. The token scope limits the tools a client sees and calls, on top of the user's own permissions:
- Tools outside the token scope are hidden from
tools/listby default. Calling one anyway answers HTTP403withWWW-Authenticate: Bearer error="insufficient_scope", scope="<granted scope> <operation>", resource_metadata="…"and the JSON-RPC error-32001(insufficient_scope, the samescopeindata). A client re-authorizes with that scope: the consent screen shows the operations already granted as "(already allowed)" and the new grant adds to them. In a JSON-RPC batch the error is carried in the body (HTTP200). listOutOfScopeTools: trueon theMcpServicealso lists the tools the user may call but the token scope does not cover, so an agent can discover them and step up when it needs one. Defaultfalse.- Tools the user cannot call because of permissions are never listed and answer
Unknown tool, as for a tool that does not exist; so do theOAuth.*operations, which are never delegated.
Multiple instances
MCP HTTP sessions live in the memory of the instance that created them. When running several instances behind a load balancer, route each client to the same instance (sticky sessions on the Mcp-Session-Id header or the client address); a request reaching another instance gets 404 Session not found.
Control how an operation appears to MCP clients:
import { Operation, OperationContext, Service } from "@webda/core";
export class PostService extends Service {
// Not exposed as a tool
@Operation({ mcp: false })
async internalCleanup() {}
// Exposed with a title and explicit hints
@Operation({ mcp: { title: "Delete a post", readOnly: false, destructive: true } })
async deletePost(ctx: OperationContext) {}
}
Security
- Permissions apply per caller: an operation is run with the caller's session and
tools/listis filtered to what that caller may call. - HTTP sessions are random ids bound to the user who created them; another user presenting the id is rejected with
403. - Every HTTP request is checked against
allowedHostsandOrigin(request host plusallowedOrigins) to prevent DNS rebinding. - Open sessions are capped by
maxSessions; at the cap the least recently used session is closed rather than refusing new ones, so nobody can lock the endpoint by opening sessions. - With the default authenticator, HTTP callers are anonymous in practice (see Who is the HTTP caller?).
webda mcpover stdio trusts whoever launches it: that process acts with the permissions of--user(or anonymous permissions without it).- With
McpOAuthAuthenticator, callers present an OAuth bearer token whose scope limits the tools they see and call (see OAuth).
Reference
- Source:
packages/mcp - Design:
docs/superpowers/specs/2026-10-03-webda-mcp-design.md