Skip to content

Commit ca7de97

Browse files
committed
Update User Guide with Mcp Server Security content
1 parent f4635ba commit ca7de97

File tree

3 files changed

+319
-1
lines changed

3 files changed

+319
-1
lines changed

embabel-agent-docs/src/main/asciidoc/reference/annotations/page.adoc

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,6 +1023,175 @@ The only special thing about it is its ability to use the `OperationContext` par
10231023

10241024
The `@AchievesGoal` annotation can be added to an `@Action` method to indicate that the completion of the action achieves a specific goal.
10251025

1026+
[[reference.annotations_secure_agent_tool]]
1027+
==== The `@SecureAgentTool` annotation
1028+
1029+
`@SecureAgentTool` declares the security contract for an Embabel `@Action` method or `@Agent`
1030+
class exposed as a remote MCP tool.
1031+
It accepts any Spring Security SpEL expression — identical syntax to `@PreAuthorize` — evaluated
1032+
against the current `Authentication` at the point of tool invocation, before Embabel's GOAP
1033+
planner executes the action body.
1034+
1035+
===== Why not `@PreAuthorize`?
1036+
1037+
Spring's `@PreAuthorize` relies on AOP proxies that intercept calls across bean boundaries.
1038+
Embabel's `DefaultActionMethodManager` invokes `@Action` methods via direct Java reflection on
1039+
the underlying bean instance, bypassing those proxies silently — so `@PreAuthorize` would have
1040+
no effect.
1041+
`@SecureAgentTool` uses its own `@Aspect` with a pointcut that fires regardless of the caller,
1042+
closing this gap.
1043+
1044+
===== Placement
1045+
1046+
`@SecureAgentTool` can be placed on the `@Agent` class to protect every `@Action` uniformly,
1047+
or on individual methods for finer-grained control.
1048+
Method-level annotation takes precedence over class-level when both are present.
1049+
1050+
**Class-level** — one annotation secures all actions in the agent, including intermediate steps
1051+
that run before the goal-achieving action:
1052+
1053+
[tabs]
1054+
====
1055+
Kotlin::
1056+
+
1057+
[source,kotlin]
1058+
----
1059+
@Agent(description = "Research a topic and return a news digest")
1060+
@SecureAgentTool("hasAuthority('news:read')") // <1>
1061+
class NewsDigestAgent {
1062+
1063+
@Action
1064+
fun extractTopic(userInput: UserInput, context: OperationContext): NewsTopic { ... } // <2>
1065+
1066+
@AchievesGoal(description = "Produce a curated news digest",
1067+
export = Export(remote = true, name = "newsDigest",
1068+
startingInputTypes = [UserInput::class]))
1069+
@Action
1070+
fun produceDigest(topic: NewsTopic, context: OperationContext): NewsDigest { ... } // <2>
1071+
}
1072+
----
1073+
1074+
Java::
1075+
+
1076+
[source,java]
1077+
----
1078+
@Agent(description = "Research a topic and return a news digest")
1079+
@SecureAgentTool("hasAuthority('news:read')") // <1>
1080+
public class NewsDigestAgent {
1081+
1082+
@Action
1083+
public NewsTopic extractTopic(UserInput userInput, OperationContext context) { ... } // <2>
1084+
1085+
@AchievesGoal(description = "Produce a curated news digest",
1086+
export = @Export(remote = true, name = "newsDigest",
1087+
startingInputTypes = {UserInput.class}))
1088+
@Action
1089+
public NewsDigest produceDigest(NewsTopic topic, OperationContext context) { ... } // <2>
1090+
}
1091+
----
1092+
====
1093+
<1> One annotation on the class protects every `@Action` in the agent.
1094+
<2> Both `extractTopic` and `produceDigest` require `news:read`.
1095+
Without class-level protection, intermediate actions like `extractTopic` would run freely
1096+
before the security check on the goal-achieving action fires.
1097+
1098+
**Method-level override** — a method-level annotation takes precedence over the class-level
1099+
expression, allowing one action to require elevated authority:
1100+
1101+
[tabs]
1102+
====
1103+
Kotlin::
1104+
+
1105+
[source,kotlin]
1106+
----
1107+
@Agent(description = "Market intelligence agent")
1108+
@SecureAgentTool("hasAuthority('market:read')") // <1>
1109+
class MarketIntelligenceAgent {
1110+
1111+
@Action
1112+
fun gatherIntelligence(subject: AnalysisSubject, context: OperationContext): String { ... }
1113+
1114+
@SecureAgentTool("hasAuthority('market:admin')") // <2>
1115+
@AchievesGoal(description = "Produce market report")
1116+
@Action
1117+
fun synthesiseReport(
1118+
subject: AnalysisSubject,
1119+
rawIntelligence: String,
1120+
context: OperationContext
1121+
): MarketIntelligenceReport { ... }
1122+
}
1123+
----
1124+
1125+
Java::
1126+
+
1127+
[source,java]
1128+
----
1129+
@Agent(description = "Market intelligence agent")
1130+
@SecureAgentTool("hasAuthority('market:read')") // <1>
1131+
public class MarketIntelligenceAgent {
1132+
1133+
@Action
1134+
public String gatherIntelligence(AnalysisSubject subject, OperationContext context) { ... }
1135+
1136+
@SecureAgentTool("hasAuthority('market:admin')") // <2>
1137+
@AchievesGoal(description = "Produce market report")
1138+
@Action
1139+
public MarketIntelligenceReport synthesiseReport(
1140+
AnalysisSubject subject,
1141+
String rawIntelligence,
1142+
OperationContext context) { ... }
1143+
}
1144+
----
1145+
====
1146+
<1> All actions default to requiring `market:read`.
1147+
<2> `synthesiseReport` requires `market:admin` — the method-level annotation overrides the class.
1148+
1149+
===== Supported expressions
1150+
1151+
Any Spring Security SpEL expression is valid:
1152+
1153+
[cols="2,3",options="header"]
1154+
|===
1155+
|Expression |Meaning
1156+
1157+
|`hasAuthority('finance:read')`
1158+
|Principal must carry this exact authority
1159+
1160+
|`hasAnyAuthority('finance:read', 'finance:admin')`
1161+
|Principal must carry at least one of the listed authorities
1162+
1163+
|`hasRole('ADMIN')`
1164+
|Principal must carry `ROLE_ADMIN` (the `ROLE_` prefix is added automatically)
1165+
1166+
|`isAuthenticated()`
1167+
|Any authenticated principal, regardless of authorities
1168+
1169+
|`hasAuthority('payments:write') and #request.amount < 10000`
1170+
|Combines an authority check with a method parameter expression
1171+
|===
1172+
1173+
===== Setup
1174+
1175+
Add the MCP security starter to your `pom.xml`:
1176+
1177+
[source,xml]
1178+
----
1179+
<dependency>
1180+
<groupId>com.embabel.agent</groupId>
1181+
<artifactId>embabel-agent-starter-mcpserver-security</artifactId>
1182+
<version>${embabel-agent.version}</version>
1183+
</dependency>
1184+
----
1185+
1186+
The starter auto-configures `SecureAgentToolAspect` and the required Spring Security
1187+
`MethodSecurityExpressionHandler`.
1188+
No additional `@EnableMethodSecurity` annotation is required.
1189+
1190+
NOTE: `@SecureAgentTool` is a method-level security control, not an HTTP-level one.
1191+
For production use, combine it with a `SecurityFilterChain` that validates JWT Bearer tokens
1192+
on MCP endpoints so unauthenticated requests are rejected before reaching the GOAP planner.
1193+
See xref:reference.integrations__mcp_security[MCP Security] for the full two-layer setup.
1194+
10261195
==== Implementing the `StuckHandler` interface
10271196

10281197
If an annotated agent class implements the `StuckHandler` interface, it can handle situations where an action is stuck itself.

embabel-agent-docs/src/main/asciidoc/reference/guardrails/page.adoc

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,4 +472,12 @@ In summary, guardrails and bean validators are complementary but distinct:
472472
- **Bean validation** ensures objects are well-formed and meet business constraints
473473
- **Guardrails** ensure AI interactions are safe and compliant with policies
474474

475-
Both can be enabled independently and serve different aspects of the AI safety stack.
475+
Both can be enabled independently and serve different aspects of the AI safety stack.
476+
477+
`@SecureAgentTool` is a third, orthogonal mechanism: it enforces _access control_ rather than
478+
content safety or data validity.
479+
Where guardrails ask "is this content acceptable?", `@SecureAgentTool` asks "is this principal
480+
allowed to invoke this agent action at all?"
481+
The two work well together — `@SecureAgentTool` prevents unauthorised principals from calling
482+
a tool, while guardrails validate the inputs and outputs of calls that are permitted.
483+
See xref:reference.annotations_secure_agent_tool[`@SecureAgentTool`] for details.

embabel-agent-docs/src/main/asciidoc/reference/integrations/page.adoc

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,147 @@ Every MCP server includes a built-in `helloBanner` tool that displays server inf
503503
}
504504
----
505505

506+
[[reference.integrations__mcp_security]]
507+
===== Security
508+
509+
Embabel MCP servers support two complementary layers of security that work together.
510+
Think of them like a building with a reception desk and locked office doors: the HTTP filter
511+
chain is the reception desk that turns away anyone without a badge, and `@SecureAgentTool`
512+
is the locked door on each individual office that checks what the badge actually permits.
513+
514+
====== Layer 1 — HTTP transport (filter chain)
515+
516+
All requests to MCP endpoints (`/sse/**`, `/mcp/**`, `/message/**`) must carry a valid JWT
517+
Bearer token or they are rejected with `401 Unauthorized` before the GOAP planner is invoked.
518+
519+
Configure a `SecurityFilterChain` and a JWT resource server in your Spring Security setup:
520+
521+
[tabs]
522+
====
523+
Kotlin::
524+
+
525+
[source,kotlin]
526+
----
527+
@Configuration
528+
@EnableWebSecurity
529+
class McpSecurityConfiguration {
530+
531+
@Bean
532+
fun mcpFilterChain(http: HttpSecurity): SecurityFilterChain {
533+
http
534+
.securityMatcher("/sse/**", "/mcp/**", "/message/**")
535+
.authorizeHttpRequests { it.anyRequest().authenticated() }
536+
.sessionManagement {
537+
it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
538+
}
539+
.oauth2ResourceServer { oauth2 ->
540+
oauth2.jwt { jwt ->
541+
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
542+
}
543+
}
544+
.csrf { it.disable() }
545+
return http.build()
546+
}
547+
548+
@Bean
549+
fun jwtAuthenticationConverter(): JwtAuthenticationConverter {
550+
val authoritiesConverter = JwtGrantedAuthoritiesConverter().apply {
551+
setAuthoritiesClaimName("authorities")
552+
setAuthorityPrefix("") // <1>
553+
}
554+
return JwtAuthenticationConverter().apply {
555+
setJwtGrantedAuthoritiesConverter(authoritiesConverter)
556+
}
557+
}
558+
}
559+
----
560+
====
561+
<1> Empty prefix means JWT claim values like `news:read` map directly to Spring Security
562+
authorities, so `hasAuthority('news:read')` in a `@SecureAgentTool` expression works without
563+
any `SCOPE_` prefix.
564+
565+
Configure JWT validation in `application.yml`:
566+
567+
[source,yaml]
568+
----
569+
spring:
570+
security:
571+
oauth2:
572+
resourceserver:
573+
jwt:
574+
public-key-location: classpath:keys/public.pem # local dev
575+
jws-algorithms: RS256
576+
# For production, use issuer-uri or jwk-set-uri instead
577+
----
578+
579+
====== Layer 2 — Method-level (`@SecureAgentTool`)
580+
581+
Enforces per-action authorization inside the GOAP execution pipeline, after the HTTP layer
582+
has validated the token.
583+
Place `@SecureAgentTool` on the `@Agent` class to protect every `@Action` in that agent:
584+
585+
[tabs]
586+
====
587+
Kotlin::
588+
+
589+
[source,kotlin]
590+
----
591+
@Agent(description = "Curated news digest agent")
592+
@SecureAgentTool("hasAuthority('news:read')") // <1>
593+
class NewsDigestAgent {
594+
595+
@Action
596+
fun extractTopic(userInput: UserInput, context: OperationContext): NewsTopic { ... } // <2>
597+
598+
@AchievesGoal(description = "Produce news digest",
599+
export = Export(remote = true, name = "newsDigest",
600+
startingInputTypes = [UserInput::class]))
601+
@Action
602+
fun produceDigest(topic: NewsTopic, context: OperationContext): NewsDigest { ... } // <2>
603+
}
604+
----
605+
606+
Java::
607+
+
608+
[source,java]
609+
----
610+
@Agent(description = "Curated news digest agent")
611+
@SecureAgentTool("hasAuthority('news:read')") // <1>
612+
public class NewsDigestAgent {
613+
614+
@Action
615+
public NewsTopic extractTopic(UserInput userInput, OperationContext context) { ... } // <2>
616+
617+
@AchievesGoal(description = "Produce news digest",
618+
export = @Export(remote = true, name = "newsDigest",
619+
startingInputTypes = {UserInput.class}))
620+
@Action
621+
public NewsDigest produceDigest(NewsTopic topic, OperationContext context) { ... } // <2>
622+
}
623+
----
624+
====
625+
<1> Class-level annotation applies to every `@Action` in this agent.
626+
<2> Both `extractTopic` (the intermediate step) and `produceDigest` (the goal action) require
627+
`news:read` — without class-level security, intermediate actions run freely before the goal
628+
action's check fires, potentially burning LLM tokens on an unauthorised request.
629+
630+
See xref:reference.annotations_secure_agent_tool[`@SecureAgentTool`] for the full annotation
631+
reference including supported SpEL expressions and method-level override behaviour.
632+
633+
====== Dependency
634+
635+
[source,xml]
636+
----
637+
<dependency>
638+
<groupId>com.embabel.agent</groupId>
639+
<artifactId>embabel-agent-starter-mcpserver-security</artifactId>
640+
<version>${embabel-agent.version}</version>
641+
</dependency>
642+
----
643+
644+
The starter auto-configures `SecureAgentToolAspect` and wires the Spring Security
645+
`MethodSecurityExpressionHandler`. No additional `@EnableMethodSecurity` is required.
646+
506647
[[reference.integrations__mcp_consuming]]
507648
===== Consuming
508649

0 commit comments

Comments
 (0)