Skip to content

Commit e62a46d

Browse files
committed
MCPServerFeaturesFilter implementation
1 parent 339da45 commit e62a46d

File tree

9 files changed

+699
-0
lines changed

9 files changed

+699
-0
lines changed

openig-core/src/main/java/org/forgerock/openig/alias/CoreClassAliasResolver.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
import org.forgerock.openig.security.TrustManagerHeaplet;
6262
import org.forgerock.openig.thread.ScheduledExecutorServiceHeaplet;
6363
import org.openidentityplatform.openig.filter.ICAPFilter;
64+
import org.openidentityplatform.openig.filter.MCPServerFeaturesFilter;
6465
import org.openidentityplatform.openig.mq.EmbeddedKafka;
6566
import org.openidentityplatform.openig.mq.MQ_IBM;
6667
import org.openidentityplatform.openig.mq.MQ_Kafka;
@@ -119,6 +120,7 @@ public class CoreClassAliasResolver implements ClassAliasResolver {
119120
ALIASES.put("MQ_Kafka", MQ_Kafka.class);
120121
ALIASES.put("MQ_IBM", MQ_IBM.class);
121122
ALIASES.put("ICAP", ICAPFilter.class);
123+
ALIASES.put("MCPServerFeaturesFilter", MCPServerFeaturesFilter.class);
122124
}
123125

124126
@Override
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
/*
2+
* The contents of this file are subject to the terms of the Common Development and
3+
* Distribution License (the License). You may not use this file except in compliance with the
4+
* License.
5+
*
6+
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
7+
* specific language governing permission and limitations under the License.
8+
*
9+
* When distributing Covered Software, include this CDDL Header Notice in each file and include
10+
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
11+
* Header, with the fields enclosed by brackets [] replaced by your own identifying
12+
* information: "Portions copyright [year] [name of copyright owner]".
13+
*
14+
* Copyright 2026 3A Systems LLC.
15+
*/
16+
17+
package org.openidentityplatform.openig.filter;
18+
19+
import org.forgerock.http.Filter;
20+
import org.forgerock.http.Handler;
21+
import org.forgerock.http.protocol.Request;
22+
import org.forgerock.http.protocol.Response;
23+
import org.forgerock.http.protocol.Status;
24+
import org.forgerock.json.JsonValue;
25+
import org.forgerock.openig.heap.GenericHeaplet;
26+
import org.forgerock.openig.heap.HeapException;
27+
import org.forgerock.services.context.Context;
28+
import org.forgerock.util.promise.NeverThrowsException;
29+
import org.forgerock.util.promise.Promise;
30+
import org.slf4j.Logger;
31+
import org.slf4j.LoggerFactory;
32+
33+
import java.io.IOException;
34+
import java.util.ArrayList;
35+
import java.util.Arrays;
36+
import java.util.Collections;
37+
import java.util.List;
38+
import java.util.Map;
39+
import java.util.stream.Collectors;
40+
41+
import static java.util.Collections.emptyList;
42+
import static org.forgerock.http.protocol.Response.newResponsePromise;
43+
import static org.forgerock.http.protocol.Responses.newInternalServerError;
44+
import static org.forgerock.json.JsonValue.field;
45+
import static org.forgerock.json.JsonValue.json;
46+
import static org.forgerock.json.JsonValue.object;
47+
48+
/**
49+
* MCPServerFeaturesFilter
50+
* <br/>
51+
* This filter enforces allow/deny policies for MCP (Management & Control Protocol)
52+
* features exchanged as JSON-RPC payloads with an MCP server. It inspects both
53+
* incoming requests and outgoing responses and removes or rejects features
54+
* according to the configured rules.
55+
*
56+
* <p>Policy enforcement logic:
57+
* <ul>
58+
* <li>Deny lists take precedence over allow lists</li>
59+
* <li>Empty allow list means all features are allowed (unless denied)</li>
60+
* <li>Non-empty allow list means only listed features are allowed</li>
61+
* <li>Denied features are always blocked, regardless of allow list</li>
62+
* </ul>
63+
*
64+
* <pre>
65+
* {
66+
* "type": "MCPFeaturesFilter",
67+
* "config": {
68+
* "allow": {
69+
* "tools": ["get_weather", "tool2"],
70+
* "prompts": ["code_review", "prompt2"]
71+
* },
72+
* "deny": {
73+
* "resources": ["file:///project/src/main.rs"],
74+
* "resources/templates": ["file:///{path}"]
75+
* }
76+
* }
77+
* }
78+
* </pre>
79+
*/
80+
public class MCPServerFeaturesFilter implements Filter {
81+
82+
private static final Logger logger = LoggerFactory.getLogger(MCPServerFeaturesFilter.class);
83+
84+
Map<MCPFeature, List<String>> allowFeatures;
85+
Map<MCPFeature, List<String>> denyFeatures;
86+
87+
public Map<MCPFeature, List<String>> getAllowFeatures() {
88+
return allowFeatures;
89+
}
90+
91+
public Map<MCPFeature, List<String>> getDenyFeatures() {
92+
return denyFeatures;
93+
}
94+
95+
@Override
96+
public Promise<Response, NeverThrowsException> filter(Context context, Request request, Handler next) {
97+
JsonValue inputValue;
98+
try {
99+
inputValue = json(request.getEntity().getJson());
100+
} catch (IOException e) {
101+
logger.debug("Error parsing JSON request body", e);
102+
return newResponsePromise(new Response(Status.BAD_REQUEST));
103+
}
104+
String method = inputValue.get("method").asString();
105+
106+
JsonValue methodNode = inputValue.get("method");
107+
if (methodNode == null || methodNode.isNull()) {
108+
logger.debug("Missing 'method' in JSON-RPC request");
109+
return newResponsePromise(new Response(Status.BAD_REQUEST));
110+
}
111+
112+
try {
113+
checkFeaturesRequest(method, inputValue);
114+
} catch (FeatureIsNotAllowedException e) {
115+
logger.warn("feature {}: {} is not allowed", e.getMcpFeature(), e.getFeatureName());
116+
Response response = getFeatureDeniedResponse(inputValue, e);
117+
return newResponsePromise(response);
118+
}
119+
120+
return next.handle(context, request)
121+
.then(response -> {
122+
JsonValue outputValue;
123+
try {
124+
outputValue = json(response.getEntity().getJson());
125+
} catch (IOException e) {
126+
logger.debug("Error parsing response JSON body", e);
127+
return newInternalServerError();
128+
}
129+
JsonValue result = outputValue.get("result");
130+
filterFeaturesResponse(method, result);
131+
response.setEntity(outputValue);
132+
return response;
133+
});
134+
}
135+
136+
private static Response getFeatureDeniedResponse(JsonValue inputValue, FeatureIsNotAllowedException e) {
137+
String errMessage = "";
138+
switch (e.getMcpFeature()){
139+
case TOOLS:
140+
errMessage = "Unknown tool: invalid_tool_name";
141+
break;
142+
case PROMPTS:
143+
errMessage = "Unknown prompt: invalid_prompt_name";
144+
break;
145+
case RESOURCES:
146+
errMessage = "Unknown resource: invalid_resource_name";
147+
break;
148+
case RESOURCES_TEMPLATES:
149+
errMessage = "Unknown resource template: invalid_resource_template_name";
150+
break;
151+
}
152+
JsonValue responseEntity = json(object(
153+
field("jsonrpc", "2.0"),
154+
field("id", inputValue.get("id")),
155+
field("error", object(
156+
field("code", -32602),
157+
field("message", errMessage)
158+
))
159+
));
160+
Response response = new Response(Status.OK);
161+
response.setEntity(responseEntity);
162+
return response;
163+
}
164+
165+
private void checkFeaturesRequest(String method, JsonValue inputValue) throws FeatureIsNotAllowedException {
166+
MCPFeature feature;
167+
switch (method) {
168+
case "tools/call":
169+
feature = MCPFeature.TOOLS;
170+
break;
171+
case "prompts/get":
172+
feature = MCPFeature.PROMPTS;
173+
break;
174+
case "resources/list":
175+
feature = MCPFeature.RESOURCES;
176+
break;
177+
case "resources/templates/list":
178+
feature = MCPFeature.RESOURCES_TEMPLATES;
179+
break;
180+
default:
181+
return;
182+
}
183+
184+
JsonValue queriedFeatureJson = inputValue.get("params").get(feature.idField);
185+
if(queriedFeatureJson == null || queriedFeatureJson.isNull()) {
186+
return;
187+
}
188+
189+
String queriedFeatureName = queriedFeatureJson.asString();
190+
191+
if (!isFeatureAllowed(feature, queriedFeatureName)) {
192+
throw new FeatureIsNotAllowedException(feature, queriedFeatureName);
193+
}
194+
}
195+
196+
private boolean isFeatureAllowed(MCPFeature feature, String featureName) {
197+
List<String> denied = this.denyFeatures.get(feature);
198+
if (denied != null && !denied.isEmpty() && denied.contains(featureName)) {
199+
return false;
200+
}
201+
202+
List<String> allowed = this.allowFeatures.get(feature);
203+
if (allowed != null && !allowed.isEmpty()) {
204+
return allowed.contains(featureName);
205+
}
206+
return true;
207+
}
208+
209+
private void filterFeaturesResponse(String method, JsonValue result) {
210+
MCPFeature feature;
211+
switch (method) {
212+
case "tools/list":
213+
feature = MCPFeature.TOOLS;
214+
break;
215+
case "prompts/list":
216+
feature = MCPFeature.PROMPTS;
217+
break;
218+
case "resources/list":
219+
feature = MCPFeature.RESOURCES;
220+
break;
221+
case "resources/templates/list":
222+
feature = MCPFeature.RESOURCES_TEMPLATES;
223+
break;
224+
default:
225+
return;
226+
}
227+
228+
List<JsonValue> returnedFeatures = result.get(feature.name).asList()
229+
.stream().map(JsonValue::json).collect(Collectors.toList());
230+
231+
List<JsonValue> filteredReturnedFeatures
232+
= filterResponseFeature(feature, returnedFeatures,
233+
this.allowFeatures.get(feature), this.denyFeatures.get(feature));
234+
235+
result.put(feature.name, filteredReturnedFeatures);
236+
237+
}
238+
239+
/**
240+
* Filter a list of feature objects.
241+
*
242+
* @param featuresList the original feature JSON objects
243+
* @param allowed allowed names (empty == no allow constraint)
244+
* @param denied denied names (empty == no deny constraint)
245+
* @return filtered list (new list instance)
246+
*/
247+
public List<JsonValue> filterResponseFeature(MCPFeature mcpFeature,
248+
List<JsonValue> featuresList,
249+
List<String> allowed, List<String> denied) {
250+
List<JsonValue> result = new ArrayList<>(featuresList);
251+
252+
if(denied != null && !denied.isEmpty()) {
253+
result = result.stream()
254+
.filter(t -> !denied.contains(t.get(mcpFeature.idField).asString()))
255+
.collect(Collectors.toList());
256+
}
257+
258+
if(allowed != null && !allowed.isEmpty()) {
259+
result = featuresList.stream()
260+
.filter(t -> allowed.contains(t.get(mcpFeature.idField).asString()))
261+
.collect(Collectors.toList());
262+
}
263+
264+
265+
266+
return result;
267+
}
268+
269+
public static class Heaplet extends GenericHeaplet {
270+
271+
@Override
272+
public Object create() throws HeapException {
273+
MCPServerFeaturesFilter filter = new MCPServerFeaturesFilter();
274+
JsonValue evaluatedConfig = config.as(evaluatedWithHeapProperties());
275+
JsonValue allowConfig = evaluatedConfig.get("allow");
276+
filter.allowFeatures = Arrays.stream(MCPFeature.values())
277+
.collect(Collectors.toUnmodifiableMap(
278+
f -> f,
279+
f -> Collections.unmodifiableList(allowConfig.get(f.name)
280+
.defaultTo(emptyList()).asList(String.class))
281+
));
282+
283+
JsonValue denyConfig = evaluatedConfig.get("deny");
284+
filter.denyFeatures = Arrays.stream(MCPFeature.values())
285+
.collect(Collectors.toUnmodifiableMap(
286+
f -> f,
287+
f -> Collections.unmodifiableList(denyConfig.get(f.name)
288+
.defaultTo(emptyList()).asList(String.class))
289+
));
290+
return filter;
291+
}
292+
}
293+
294+
public enum MCPFeature {
295+
TOOLS("tools", "name"),
296+
PROMPTS("prompts", "name"),
297+
RESOURCES("resources", "uri"),
298+
RESOURCES_TEMPLATES("resources/templates", "uriTemplate");
299+
300+
private final String name;
301+
302+
private final String idField;
303+
MCPFeature(String name, String idField) {
304+
this.name = name;
305+
this.idField = idField;
306+
}
307+
}
308+
309+
static class FeatureIsNotAllowedException extends Exception {
310+
311+
private final String featureName;
312+
313+
private final MCPFeature mcpFeature;
314+
315+
public FeatureIsNotAllowedException(MCPFeature mcpFeature, String featureName) {
316+
this.mcpFeature = mcpFeature;
317+
this.featureName = featureName;
318+
}
319+
320+
public MCPFeature getMcpFeature() {
321+
return mcpFeature;
322+
}
323+
324+
public String getFeatureName() {
325+
return featureName;
326+
}
327+
328+
}
329+
}

0 commit comments

Comments
 (0)