@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-coreAn 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 method | What it actually is |
|---|---|
tools/list | ToolRegistry.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/call | The 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:
kind | Over MCP |
|---|---|
read | Listed and callable. Advertised with annotations.readOnlyHint: true. |
action | Neither listed nor callable, unless actions: 'execute'. |
agent (handoff) | Never exposed, whatever actions says. |
ask · skill · memory | Never 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
UnauthorizedExceptionkeeps its 401; a deliberateForbiddenExceptionkeeps its 403; a plainErroris 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 emptyid, 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.
| Situation | Answer |
|---|---|
No credential, or one the resolver does not know, on POST / GET / DELETE | 401, with a WWW-Authenticate: Bearer challenge |
| A personal agent calling an offered tool whose scope it was not granted | 403 insufficient_scope — see personal agents |
The host resolver threw a plain Error, or returned a non-actor | 401 |
| A session id presented by a different actor | 403 |
| A session id this process does not hold | 404 — the client re-initializes rather than retrying |
POST with no session id whose body is not an initialize | 400 |
GET / DELETE with no session id | 400 |
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:
- Guards run first, against an
ExecutionContextwhoseswitchToHttp().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. - Pipes run on each parameter — the ones on the declaration and your global ones. A
ParseIntPipeparses, aValidationPipevalidates the DTO. - Interceptors run around the handler.
- 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 answered | The 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 declares | An 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: number | query.limit, typed from the declaration, never required |
@Query() dto: SearchDto | an open query object |
@Body('term') term: string | body.term, required |
@Body() dto: CreateOrderDto | an 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
| Declaration | Why |
|---|---|
No kind, or no description | Nothing 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 controller | There is no request for Nest to build the instance from |
A name an @AiTool or another route already claims | A 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/listoffers every tool written for personal agents — one whoserolesnamepersonal_agentor ascope:role, granted or not;tools/callruns only the tools whose roles it holds. A call to an offered tool whose scope was not granted is refused before it runs with403andWWW-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 token | Answer |
|---|---|
| none | 401 Bearer |
| refused by the verifier | its status, its challenge verbatim (invalid_token, invalid_dpop_proof, use_dpop_nonce) and its headers (DPoP-Nonce) |
| a signed-out Session's | 403 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 all | 501 |
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
| Option | Default | What 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 |
allowedTools | every exposable tool the actor may use | The names this surface exposes at all — on the list and on the call |
routes.principal | the resolved actor on request.user | How the MCP actor becomes the dispatched request's principal |
oauth | — | { issuer, scopesSupported?, resourceName? }: serve the protected-resource metadata and name it in challenges |
baseUrl | the request's origin | The 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 provided | Why, and what to do |
|---|---|
| A route on a request-scoped controller | There 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 callers | Parking 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 limiting | Host middleware on the route. |
| IP allow-lists, DNS-rebinding protection | Host middleware on the route. The MCP SDK's own allowedHosts / allowedOrigins options are deprecated in favour of exactly that. |
| A shared session store | Sessions 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
| Export | What it is |
|---|---|
AgentMcpServerModule.forRoot(options) / .forRootAsync(options) | The NestJS wiring — mounts the controller under path. |
AgentMcpServerController | The Streamable HTTP endpoint itself. |
createAgentMcpServer(options) | The MCP Server over a registry + policy, framework-free. |
BearerTokenActorResolver / McpBearerGrant | Issued tokens bound to the identities they act as. |
authenticateMcpRequest({ auth, request }) | One request's identity, or an HttpException. |
mcpExposureRefusal · isToolExposedOverMcp · assertToolExposedOverMcp · McpToolNotExposedError | The exposure decision on its own, and its refusal reason. |
McpActionPolicy · McpExposureInput | 'deny' | 'execute', and what the decision reads. |
actorFromAuthInfo · isActor · McpUnauthenticatedError · McpAuthInfo | Reading the acting actor off a verified AuthInfo. |
McpSessionStore / McpSession | The live sessions this process holds — size, and a shutdown hook that closes them. Exported by the module. |
Mcp(options) · McpRouteOptions | The route decorator, and what it takes. |
McpRouteDeclarationError · McpRouteToolNameCollisionError | The two boot refusals, each naming the route. |
McpRouteHttpError | An HttpException a route answered a dispatched call with, keeping its status. |
defaultMcpRoutePrincipal · McpRoutePrincipal · McpRoutePrincipalFactory | The principal a dispatched request carries, and the seam for replacing it. |
McpRouteDispatcher · McpRouteDiscoveryService | The pipeline dispatch, and the boot walk that finds @Mcp() routes. |
readMcpRouteMetadata · defaultRouteToolName | What a route declared, and the tool name it gets — for predicting an allowedTools entry. |
routeInputSchema · McpRouteInput · readNestRoute · NestRoute · ROUTE_PARAM | Route declarations → the schema and the arguments derived from them. |
AGENT_MCP_ROUTE_TOOLS | The DI token for the registry route-derived tools land in. |
toMcpInputSchema / McpInputSchema | Standard Schema → the JSON Schema tools/list advertises. |
AGENT_MCP_SERVER_OPTIONS · AGENT_MCP_SERVER_PATH | The options and mount-path DI tokens. |
McpChallengeException · McpChallengeFilter · McpChallenge · McpChallengeError | A refusal carrying its WWW-Authenticate challenge, and the filter that answers it. |
McpOAuthOptions · protectedResourceMetadata · ProtectedResourceMetadata · announcedPoppyIssuer · POPPY_ISSUER_SLOT | The RFC 9728 document. |
AgentMcpProtectedResourceModule · AgentMcpServerOptionsModule | The modules serving the metadata at the app root, and sharing the options. |
poppyActorResolver · asPersonalAgent · globalPoppyAuthenticate · POPPY_AUTHENTICATE_SLOT · PoppyAuthenticate · PoppyPrincipal · PoppyAuthRequest · PoppyAuthResult · PoppyAuthFailure · PoppyAuthOptions · PoppyActorResolverOptions | Personal 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.
@dudousxd/nestjs-agent-mcp
An MCP client — import an external Model Context Protocol server's tools as governed agent tools. stdio + streamable HTTP, HITL-gated by default, the server's JSON Schema enforced.
@dudousxd/nestjs-agent-poppy
Personal Agent Protocol ("Poppy", Draft 0.1, experimental) — the company-side conversation endpoint, your agent's connections to other Poppy companies, and a reference Personal Agent (library + poppy-agent CLI).