@@ -1023,6 +1023,175 @@ The only special thing about it is its ability to use the `OperationContext` par
10231023
10241024The `@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
10281197If an annotated agent class implements the `StuckHandler` interface, it can handle situations where an action is stuck itself.
0 commit comments