Aviary
Packages

@dudousxd/nestjs-agent-mcp-server

An MCP server — expose this deployment's @AiTools, and the controller routes you mark with @Mcp(), to an external Model Context Protocol client, over the agent's own registry and roles policy, with action tools off the surface by default.

pnpm add @dudousxd/nestjs-agent-mcp-server @dudousxd/nestjs-agent @dudousxd/nestjs-agent-core

An MCP server for the agent. It mounts a Streamable HTTP endpoint on your app, and an external MCP client — Claude Desktop, an editor, a CI job — reaches the tools this deployment already has, through the gates a turn goes through and no wider.

Two things reach that client: your @AiTool classes, and any controller route you mark @Mcp() — dispatched through its own guards, pipes and interceptors, so a capability that already exists as an endpoint does not have to be written out again as a tool.

Built on the official @modelcontextprotocol/sdk.

The opposite direction to -mcp

@dudousxd/nestjs-agent-mcp is the client: it imports a remote server's tools into this app, and treats a remote tool as untrusted input — an imported tool is HITL-gated by default, its descriptions and results are attacker-controlled text.

This package is the server: it exposes this deployment's tools, and treats a remote caller as an untrusted caller — identity is required, roles decide reach, and a tool whose gate this surface cannot honour is refused rather than listed.

They share no code, only the SDK. Install either, both, or neither.

Mount it

import { AgentModule } from '@dudousxd/nestjs-agent';
import { AgentMcpServerModule, BearerTokenActorResolver } from '@dudousxd/nestjs-agent-mcp-server';

@Module({
  imports: [
    AgentModule.forRoot({ /* … */ }),
    AgentMcpServerModule.forRoot({
      name: 'Acme Agent',
      version: '1.0.0',
      auth: new BearerTokenActorResolver([
        { token: process.env.MCP_CI_TOKEN ?? '', actor: { id: 'ci', roles: ['ANALYST'] } },
      ]),
    }),
  ],
})
export class AppModule {}

POST | GET | DELETE /mcp is the endpoint — POST for initialize and requests, GET for the server-to-client SSE stream, DELETE to terminate the session. path moves it.

Import AgentMcpServerModule after AgentModule, whose global AGENT_TOOL_REGISTRY and AGENT_ROLES_POLICY it injects and serves from.

forRootAsync takes the usual imports / inject / useFactory, for auth that only exists at runtime — a key list from config, a JWT verifier built from a secret:

AgentMcpServerModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  path: 'mcp',
  useFactory: (config: ConfigService) => ({
    name: 'Acme Agent',
    version: '1.0.0',
    auth: new BearerTokenActorResolver([
      { token: config.getOrThrow('MCP_CI_TOKEN'), actor: { id: 'ci', roles: ['ANALYST'] } },
    ]),
  }),
});

path sits on the async options object rather than inside the factory result: routes are mounted before an async factory resolves, so routing metadata has to be static.


The same registry, the same policy

Neither half of this server is a second gate written to agree with the loop's:

MCP methodWhat it actually is
tools/listToolRegistry.definitionsFor(actor, policy, allowedTools) — the agent's own four filter layers (allow-list, enabled, the RolesPolicy, the tool's canUse) — then every kind this surface will not run is dropped.
tools/callThe exposure decision again, then ToolRegistry.invoke — which re-checks enabled, the RolesPolicy and canUse, and re-validates the arguments against the tool's real schema, before the handler runs.

A role-checked tool stays role-checked, a canUse still runs, a disabled tool is still absent, and a client holding a list taken before a demotion gains nothing by calling from it. See Identity & authorization for what those gates are.

The acting actor is read from the transport's verified auth on every request, not once at initialize — a long-lived MCP session cannot outlive the identity it was opened with.


What crosses the boundary

The agent's tool kinds mean different things on a surface with no human and no turn, so not all of them cross:

kindOver MCP
readListed and callable. Advertised with annotations.readOnlyHint: true.
actionNeither listed nor callable, unless actions: 'execute'.
agent (handoff)Never exposed, whatever actions says.
ask · skill · memoryNever exposed, whatever actions says.

An action tool is not callable by default

In a turn, an action never auto-executes: the turn parks and waits for a person to approve the call (HITL). An MCP client is a program. There is nobody to park for and nobody to ask, so the approval the tool was declared to require cannot happen — and the answer is to keep the tool off a surface that cannot honour its gate, rather than to quietly auto-execute it. It is absent from tools/list, and a caller that guesses the name is refused with the reason.

What `actions: 'execute'` accepts

Setting it is the deployment stating out loud that this caller may act without approval. It removes the human from every action tool the caller's roles reach — not from one you had in mind — and those tools were declared action precisely because someone judged their effects to warrant a person. It is a named opt-in rather than the default for that reason.

If you set it, bound it on both sides: allowedTools for the names, and the roles you put on the calling identity for everything else.

A write an MCP caller can make: confirmed tools

Keeping action tools off this surface does not mean no writes: a confirmed tool is a read tool whose gate is inside it — it is listed (with confirm / confirmToken in its schema), its first call previews and returns a single-use confirmToken, and only the same arguments plus that token commit. The client's model relays the preview to its user to get there.

What a call returns: the domain output, not the UI

tools/call answers with the tool's execute result as JSON text. There is no conversation behind an MCP call, so ctx.emitUi resolves to an id and drops the component, and a present hook runs but what it returns is dropped too. No text fallback is produced either: keep everything an MCP caller needs in the domain output. An action tool reached through actions: 'execute' still runs its preflight (phase: 'execute') before execute. See One answer, every surface.

The generic genui tools (ui__show_*, the tree and show tools) are read tools, so they are listed here as well, although a call has nowhere to draw. Name the tools you mean to expose in allowedTools to keep them off.

Loop-served kinds are never exposed

An agent (handoff) tool is registered with a stub handler, because the loop is what performs the delegation — reaching it through the registry runs the stub, which answers {} and delegates to nobody. ask, skill and memory are settled by the loop against a run and are never registered at all. Refusing all four keeps that true of this surface even if something registers one out of band, and it means actions: 'execute' cannot widen its way into them.

Closing an empty roles list on this surface

The MCP server serves under the agent's own RolesPolicy, so by default an MCP caller reaches exactly what a chat would — including a tool that names no roles, which is open. A deployment whose chat is open but whose MCP surface is reached by callers it does not otherwise know can close it there alone:

AgentMcpServerModule.forRoot({ name: 'Acme Agent', version: '1.0.0', auth, emptyRoles: 'deny' });

A tool whose roles come out empty (declared [], or none declared and no defaultRoles) is then neither listed nor callable over MCP; an ability-gated tool is left to the policy. Set AgentModule's own emptyRoles: 'deny' to close both surfaces.

allowedTools is enforced on the call, not only on the list

AgentMcpServerModule.forRoot({
  name: 'Acme Agent',
  version: '1.0.0',
  auth,
  allowedTools: ['search_docs', 'lookup_order'],
});

ToolRegistry.invoke knows nothing about the allow-list — definitionsFor applies it where the list is built, and nothing applies it on the way in. So this package re-checks it on tools/call: without that, a caller who simply guesses a name reaches a tool the deployment deliberately left off its MCP surface.

Omit it and the surface is every tool of an exposable kind the calling actor's roles already allow. An empty array is an allow-list that lists nothing, not the absence of one — it exposes no tools.


Identity is required, and never invented

auth is an ActorResolver — the same seam AgentModule.forRoot({ actorResolver }) takes. It has no default and no fallback actor: this option decides which of your tools a program you did not write gets to run, so it is a compile-time obligation rather than something that can be left off.

  • Reject by throwing. An UnauthorizedException keeps its 401; a deliberate ForbiddenException keeps its 403; a plain Error is mapped to 401, not allowed to become a 500 with a logged stack for what is a routine anonymous probe.
  • A resolver that answers with something that is not an actor — no id, an empty id, roles that are not strings — is treated as having failed to identify the caller, rather than handing a malformed identity to the roles policy.
  • No role is granted by default. An actor with no roles reaches no tools, which is the right starting point for a new integration.

BearerTokenActorResolver

The machine-to-machine case: a fixed list of issued tokens, each bound to the identity it acts as, compared in constant time so a wrong key cannot be refined one character at a time.

new BearerTokenActorResolver([
  { token: config.getOrThrow('MCP_CI_TOKEN'), actor: { id: 'ci', roles: ['ANALYST'] } },
  { token: config.getOrThrow('MCP_RELEASE_TOKEN'), actor: { id: 'release', roles: ['OPS'] } },
]);

A grant's reach is exactly the roles its actor carries; the resolver grants none of its own. It refuses to be constructed with an empty token, which a bare Authorization: Bearer header would otherwise match — that is to say, which anyone who knows the endpoint exists would match.

When the caller is a person your app already knows, pass the resolver you gave AgentModule instead, so one user has one set of roles on both surfaces. Any resolver works: read a session, verify a JWT, call your gateway.


Sessions, and how a request is refused

A session id travels in a plain mcp-session-id header and owns an open SSE stream that server messages are written to. Holding one is not permission to act as the identity that opened it, so a session is bound to its opener.

SituationAnswer
No credential, or one the resolver does not know, on POST / GET / DELETE401, with a WWW-Authenticate: Bearer challenge
A personal agent calling an offered tool whose scope it was not granted403 insufficient_scope — see personal agents
The host resolver threw a plain Error, or returned a non-actor401
A session id presented by a different actor403
A session id this process does not hold404 — the client re-initializes rather than retrying
POST with no session id whose body is not an initialize400
GET / DELETE with no session id400

Every method authenticates first, before the transport is handed the request, so an unidentified caller never reaches the MCP machinery.

Refusals inside a call are different in kind: a tool that is not exposed, not enabled, not allowed by the policy, or called with arguments that fail its schema comes back as a normal MCP tool result with isError: true and the reason as text. A request with no resolvable identity is a protocol error, so a client can tell "you are not authenticated" from "the tool said no" — and so is a status a @Mcp() route answered with, which is the route's verdict on the call rather than a result it produced.


A route you already wrote

Every Nest app already states its capabilities as routes: a path, validated DTOs, and the authorization their author wrote as guards. Writing a parallel @AiTool for each one states the same thing twice, and the copy drifts.

@Mcp() exposes the route itself.

import { Mcp } from '@dudousxd/nestjs-agent-mcp-server';

@Controller('orders')
export class OrdersController {
  @Get(':id')
  @UseGuards(JwtGuard, OrderAccessGuard)
  @Mcp({ kind: 'read', description: 'Read one order by its id.', roles: ['ANALYST'] })
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.orders.findOne(id);
  }
}

A client sees a tool called orders_find_one taking { params: { id } } — derived from the route's own @Param() / @Query() / @Body() declarations, not from a schema written a second time.

A call runs the pipeline, not the method

Calling findOne directly would skip @UseGuards, the pipes, and the interceptors — which is to say it would skip the authorization that made the route safe to expose in the first place. So a tools/call is dispatched through Nest's own ExternalContextCreator, the seam Nest uses to run a controller method from a transport that is not HTTP:

  1. Guards run first, against an ExecutionContext whose switchToHttp().getRequest() is the dispatched request. A class-based @UseGuards(OrderAccessGuard) is resolved out of the controller's own module, exactly as the router resolves it.
  2. Pipes run on each parameter — the ones on the declaration and your global ones. A ParseIntPipe parses, a ValidationPipe validates the DTO.
  3. Interceptors run around the handler.
  4. The handler returns, and its return value is the tool result.

createParamDecorator parameters work too: Nest runs their factory against the same execution context, so a @CurrentUser() reads the same principal a guard does.

A guard's refusal fails the call

An HttpException escaping the route comes back as a protocol error carrying the status the route chose — data: { httpStatus: 403 } — not a tool result with an error in it. A 403 reported as ordinary tool output reads to a model as "that did not work, try different arguments", when the answer is that this caller may not do it at all.

The route answeredThe client gets
400 / 422-32602 Invalid params, data.httpStatus
Any other 4xx — including a guard's 403-32600 Invalid request, data.httpStatus
5xx-32603 Internal error, data.httpStatus

kind is explicit, and never inferred

@Mcp({ kind: 'read', description: '…' })    // listed and callable
@Mcp({ kind: 'action', description: '…' })  // off the surface unless actions: 'execute'

There is no default and nothing is read off the HTTP verb. A GET that triggers a side effect and a POST that only searches are both ordinary, so deriving GET → read / POST → action is a guess that is wrong in both directions — and being wrong in the first direction means an effect running for a program nobody approved anything for. One word from the author removes the whole class of accident: omitting kind does not compile, and does not boot.

The MCP actor is the request's principal

The Actor the surface resolved goes on request.user, which is where Passport, AuthGuard and most hand-written roles guards look — and Actor's { id, roles } is what a roles guard reads.

Nothing is carried over from the inbound MCP request. The credential a caller presented to /mcp authenticated them to that surface, through the ActorResolver you configured. Replaying it at an internal route would both re-authenticate a token your guards were never meant to accept and let an MCP caller choose the headers that arrive in front of them. The identity that crosses is the resolved actor and nothing else.

Where your guards read something else, say so:

AgentMcpServerModule.forRoot({
  name: 'Acme Agent',
  version: '1.0.0',
  auth,
  routes: {
    principal: ({ actor, route }) => ({
      user: { sub: actor.id, roles: actor.roles ?? [] },
      headers: { 'x-internal-caller': `mcp:${route.tool}` },
    }),
  },
});

The schema it derives

The route declaresAn MCP client is shown
@Param()every :name in the path, each a required string under params
@Param('id')just params.id, required
@Query('limit') limit: numberquery.limit, typed from the declaration, never required
@Query() dto: SearchDtoan open query object
@Body('term') term: stringbody.term, required
@Body() dto: CreateOrderDtoan open body object, named after the DTO

Arguments arrive in the three slots a request has — { params, query, body } — so a path parameter and a body field of the same name cannot collide. Path and query values are reduced to the text an HTTP request delivers, so a ParseIntPipe parses exactly what it would have parsed on the wire.

Why a whole-object DTO is an open slot

A DTO's fields are erased by the time the app runs; only its constructor survives, which is why the slot is named after it (A CreateOrderDto…) rather than described. The client loses a hint and nothing else: the route's own ValidationPipe judges what arrives in that slot, so a body that does not fit is refused whatever the model was told — the same trade this package already makes for a tool schema it cannot introspect.

Narrow it by naming the fields you want a model to fill: @Body('term') term: string is derived exactly.

A second door into the same house

A route-derived tool is an ordinary ToolSpec, so nothing about the surface is relaxed for it: the RolesPolicy is asked about it, enabled drops it, an action is neither listed nor callable without actions: 'execute', allowedTools is re-checked on the call, and an unidentified caller is answered 401 before any of it.

They live in a registry of their own, merged only where the MCP surface is served. Mounting an MCP server should not change what the agent loop offers a model, and a route-derived tool is dispatched through a request pipeline a turn has no request for.

What fails the boot, naming the route

DeclarationWhy
No kind, or no descriptionNothing is inferred from the verb, and the description is what a model reads
@All()A dispatched request has to carry one definite verb
@Res() / @Next()The route writes the response itself, so it returns nothing to answer with
@Session() · @UploadedFile(s)() · @RawBody()An MCP call carries JSON arguments and none of these
A request-scoped controllerThere is no request for Nest to build the instance from
A name an @AiTool or another route already claimsA registry keeps the last writer, so the winner would depend on module load order

Two things a real request has, deliberately absent

Middleware (configure(consumer)) does not run: it is bound to the HTTP server's routing table, and there is no HTTP server in this path. Middleware parses, logs and sets CORS; in Nest, authorization is a guard, and guards do run.

Exception filters do not run: a filter exists to render an error into an HTTP response, and the response here is a tool result. A global filter answering 200 with an error body would turn a guard's refusal into a call that looks like it succeeded. The exception travels instead, keeping the status the route chose.


OAuth discovery

Give the module an oauth option and an OAuth-aware MCP client can find where to log in by itself: the module serves the RFC 9728 protected-resource metadata at the app root, and every 401 (and the 403 step-ups below) names it.

AgentMcpServerModule.forRoot({
  name: 'Acme Agent',
  version: '1.0.0',
  auth: myTokenResolver,
  oauth: { issuer: 'https://auth.acme.com', scopesSupported: ['orders:read'], resourceName: 'Acme' },
  baseUrl: 'https://app.acme.com', // the public origin; default: the request's own
});
GET /.well-known/oauth-protected-resource/mcp
→ { "resource": "https://app.acme.com/mcp", "authorization_servers": ["https://auth.acme.com"], … }

POST /mcp   (no token)
→ 401  WWW-Authenticate: Bearer resource_metadata="https://app.acme.com/.well-known/oauth-protected-resource/mcp"

A 401 says error="invalid_token" when a token was sent. With neither oauth nor an announced Poppy issuer (below) there is no document (404) and the challenge is a bare Bearer. The document is built from path and baseUrl: an app with a global prefix should exclude the well-known route from it, or the document is not where clients look.


Personal agents (Poppy)

Experimental — Personal Agent Protocol Draft 0.1

This tracks Personal Agent Protocol ("Poppy") Draft 0.1, a spec still in development. Everything in this section WILL change as the spec evolves — possibly in breaking ways, outside semver majors while it is a draft.

A personal agent is a caller acting for a person from outside this app: its actor carries the personal_agent role plus one scope:<id> role per scope its user granted. The MCP server's policy is always wrapped in core's personalAgentGate (idempotent, also over emptyRoles: 'deny'), so for such an actor:

  • tools/list offers every tool written for personal agents — one whose roles name personal_agent or a scope: role, granted or not;
  • tools/call runs only the tools whose roles it holds. A call to an offered tool whose scope was not granted is refused before it runs with 403 and WWW-Authenticate: Bearer error="insufficient_scope", scope="<missing>" (+ resource_metadata), the MCP scope step-up — the agent asks its user for that scope instead of reading a tool error. Every message of a batched body is checked;
  • a tool not written for personal agents (no roles, or app roles only) is never offered, and a call to it is the server's ordinary refusal. Tools this surface does not expose (actions, allowedTools) stay "not exposed", never a step-up.

Every other actor is untouched.

@AiTool({ name: 'read_orders', kind: 'read', roles: ['scope:orders:read'], /* … */ })
@AiTool({ name: 'cancel_order', kind: 'action', roles: ['scope:orders:write'], /* … */ })

Poppy Session tokens: poppyActorResolver

import { poppyActorResolver } from '@dudousxd/nestjs-agent-mcp-server';

AgentMcpServerModule.forRoot({ name: 'Acme', version: '1.0.0', auth: poppyActorResolver() });

It verifies the request's Bearer / DPoP Session Token with authenticate — default: the verifier the authorization server installs in globalThis[Symbol.for('@adonis-agora/poppy:authenticate')], read per request — handing it { method, url, headers } (url: the public URL of the request, or the resource option). A signed-in principal acts as principal.actor ?? toActor(principal) ?? { id: accountId }, made a personal agent of the token's scopes.

The tokenAnswer
none401 Bearer
refused by the verifierits status, its challenge verbatim (invalid_token, invalid_dpop_proof, use_dpop_nonce) and its headers (DPoP-Nonce)
a signed-out Session's403 Bearer error="sign_in_required" (Poppy §4.3) — not invalid_token, which would only make the agent mint another token of the same kind
no verifier configured at all501

Verifying tokens yourself? asPersonalAgent(actor, scopes) turns any actor into a personal agent of those scopes; any resolver can throw McpChallengeException(status, { error, scope, description, headers }) to pick the challenge a refusal reaches the client with.

The Poppy issuer

A listed MCP server's protected-resource metadata must name the Poppy issuer (Poppy §6). When the authorization server announces one in globalThis[Symbol.for('@adonis-agora/poppy:issuer')] (read per request), it is added to authorization_servers next to the oauth issuer, once — and the document is served with just that issuer when no oauth option is configured.


Options

OptionDefaultWhat it does
name, version— (required)Reported to clients in the initialize handshake
auth— (required)The ActorResolver deciding who the caller is
path'mcp'Route the endpoint mounts at
actions'deny''execute' lets action tools run with no human approval
allowedToolsevery exposable tool the actor may useThe names this surface exposes at all — on the list and on the call
routes.principalthe resolved actor on request.userHow the MCP actor becomes the dispatched request's principal
oauth—{ issuer, scopesSupported?, resourceName? }: serve the protected-resource metadata and name it in challenges
baseUrlthe request's originThe public origin used in the metadata and challenges

Input schemas

A tool's Standard Schema becomes the JSON Schema tools/list advertises: Zod goes through the SDK's own converter, and a schema carrying the Standard JSON Schema extension (Valibot, ArkType, Zod 4) hands its input() document over. Other keywords — $defs, additionalProperties, descriptions — ride along untouched, so nested references still resolve on the client.

A schema that can be introspected neither way degrades to a permissive object rather than failing the listing. The client loses the parameter shapes and nothing else: ToolRegistry.invoke validates the real schema before the handler runs, so a call that does not fit it is refused whatever the client was told.

A tool call with no run behind it

There is no turn here, so a handler's AiToolCtx carries the actor the request authenticated as and ids that name the MCP call rather than a run: threadId: 'mcp:<session>', and runId / requestId of mcp:<session>:<request>. A tool that reads ctx.actor behaves identically on both surfaces; one that expects to find its own run persisted will not find one.


Deliberately not in this package

An operator putting this endpoint on the internet is deciding what goes in front of it. These are the parts it does not supply.

Not providedWhy, and what to do
A route on a request-scoped controllerThere is no request to build the instance from, so it fails the boot rather than being served against a half-built one.
An approval flow for MCP callersParking a tools/call on a human decision needs a run, a persisted tool-call row and a signal to wait on. A half-built version of that is worse than an explicit refusal, so action tools are refused instead — see HITL for what the real one costs.
Rate limitingHost middleware on the route.
IP allow-lists, DNS-rebinding protectionHost middleware on the route. The MCP SDK's own allowedHosts / allowedOrigins options are deprecated in favour of exactly that.
A shared session storeSessions are held in memory, per pod. A fleet behind a load balancer needs session affinity on the MCP route, or a client's second request lands on a pod that never saw its initialize.

Exports

ExportWhat it is
AgentMcpServerModule.forRoot(options) / .forRootAsync(options)The NestJS wiring — mounts the controller under path.
AgentMcpServerControllerThe Streamable HTTP endpoint itself.
createAgentMcpServer(options)The MCP Server over a registry + policy, framework-free.
BearerTokenActorResolver / McpBearerGrantIssued tokens bound to the identities they act as.
authenticateMcpRequest({ auth, request })One request's identity, or an HttpException.
mcpExposureRefusal · isToolExposedOverMcp · assertToolExposedOverMcp · McpToolNotExposedErrorThe exposure decision on its own, and its refusal reason.
McpActionPolicy · McpExposureInput'deny' | 'execute', and what the decision reads.
actorFromAuthInfo · isActor · McpUnauthenticatedError · McpAuthInfoReading the acting actor off a verified AuthInfo.
McpSessionStore / McpSessionThe live sessions this process holds — size, and a shutdown hook that closes them. Exported by the module.
Mcp(options) · McpRouteOptionsThe route decorator, and what it takes.
McpRouteDeclarationError · McpRouteToolNameCollisionErrorThe two boot refusals, each naming the route.
McpRouteHttpErrorAn HttpException a route answered a dispatched call with, keeping its status.
defaultMcpRoutePrincipal · McpRoutePrincipal · McpRoutePrincipalFactoryThe principal a dispatched request carries, and the seam for replacing it.
McpRouteDispatcher · McpRouteDiscoveryServiceThe pipeline dispatch, and the boot walk that finds @Mcp() routes.
readMcpRouteMetadata · defaultRouteToolNameWhat a route declared, and the tool name it gets — for predicting an allowedTools entry.
routeInputSchema · McpRouteInput · readNestRoute · NestRoute · ROUTE_PARAMRoute declarations → the schema and the arguments derived from them.
AGENT_MCP_ROUTE_TOOLSThe DI token for the registry route-derived tools land in.
toMcpInputSchema / McpInputSchemaStandard Schema → the JSON Schema tools/list advertises.
AGENT_MCP_SERVER_OPTIONS · AGENT_MCP_SERVER_PATHThe options and mount-path DI tokens.
McpChallengeException · McpChallengeFilter · McpChallenge · McpChallengeErrorA refusal carrying its WWW-Authenticate challenge, and the filter that answers it.
McpOAuthOptions · protectedResourceMetadata · ProtectedResourceMetadata · announcedPoppyIssuer · POPPY_ISSUER_SLOTThe RFC 9728 document.
AgentMcpProtectedResourceModule · AgentMcpServerOptionsModuleThe modules serving the metadata at the app root, and sharing the options.
poppyActorResolver · asPersonalAgent · globalPoppyAuthenticate · POPPY_AUTHENTICATE_SLOT · PoppyAuthenticate · PoppyPrincipal · PoppyAuthRequest · PoppyAuthResult · PoppyAuthFailure · PoppyAuthOptions · PoppyActorResolverOptionsPersonal agents (Poppy, experimental).

Without the module

createAgentMcpServer is the whole feature; the controller is HTTP wiring around it.

const server = createAgentMcpServer({
  name: 'Acme Agent',
  version: '1.0.0',
  registry,            // the agent's own ToolRegistry
  policy,              // the agent's own RolesPolicy
  actions: 'deny',
});
await server.connect(transport);

actorFromAuth is the seam for where the acting actor is read from the transport's verified AuthInfo; its default refuses a request that carries none.

On this page