From 673d24666331464de43a86f560992f730a1cb7d8 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Tue, 10 Feb 2026 16:42:04 -0700
Subject: [PATCH 01/27] :sparkles: Start processing tool responses with nemo
check
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
.../examples/nemocheck/k8deploy/deploy.yaml | 14 +-
.../examples/nemocheck/nemocheck/plugin.py | 142 ++++++++++++++++--
src/server.py | 105 ++++++++++---
3 files changed, 218 insertions(+), 43 deletions(-)
diff --git a/plugins/examples/nemocheck/k8deploy/deploy.yaml b/plugins/examples/nemocheck/k8deploy/deploy.yaml
index 5354549..0971cb8 100644
--- a/plugins/examples/nemocheck/k8deploy/deploy.yaml
+++ b/plugins/examples/nemocheck/k8deploy/deploy.yaml
@@ -34,22 +34,22 @@ spec:
- name: MODEL_NAME # Currently only for logging.
value: "meta-llama/llama-3-3-70b-instruct"
- name: CHECK_ENDPOINT
- value: "http://nemo-guardrails-service:8000/v1/guardrail/checks"
+ value: "http://nemo-guardrails-service:50053/v1/guardrail/checks"
- name: PLUGINS_SERVER_HOST
value: "0.0.0.0"
- name: PLUGINS_ENABLED
value: "false"
- - name: PLUGINS_CLI_COMPLETION
+ - name: PLUGINS_CLI_COMPLETION
value: "false"
- - name: PLUGINS_CLI_MARKUP_MODE
+ - name: PLUGINS_CLI_MARKUP_MODE
value: "rich"
- - name: PLUGINS_CONFIG
+ - name: PLUGINS_CONFIG
value: "./resources/plugins/config.yaml"
- # - name: CHUK_MCP_CONFIG_PATH
+ # - name: CHUK_MCP_CONFIG_PATH
# value: "./resources/runtime/config.yaml"
- - name: MCP_SSL_ENABLED
+ - name: MCP_SSL_ENABLED
value: "false"
- - name: MCP_SSL_CERT_REQS
+ - name: MCP_SSL_CERT_REQS
value: "0"
- name: LOGLEVEL
value: "DEBUG"
diff --git a/plugins/examples/nemocheck/nemocheck/plugin.py b/plugins/examples/nemocheck/nemocheck/plugin.py
index 82e96a7..4983795 100644
--- a/plugins/examples/nemocheck/nemocheck/plugin.py
+++ b/plugins/examples/nemocheck/nemocheck/plugin.py
@@ -36,8 +36,12 @@
log_level = os.getenv("LOGLEVEL", "INFO").upper()
logger.setLevel(log_level)
-MODEL_NAME = os.getenv("NEMO_MODEL", "meta-llama/llama-3-3-70b-instruct") # Currently only for logging.
-CHECK_ENDPOINT = os.getenv("CHECK_ENDPOINT", "http://nemo-guardrails-service:8000")
+MODEL_NAME = os.getenv(
+ "NEMO_MODEL", "meta-llama/llama-3-3-70b-instruct"
+) # Currently only for logging.
+CHECK_ENDPOINT = os.getenv(
+ "CHECK_ENDPOINT", "http://nemo-guardrails-service:8000"
+)
class NemoCheck(Plugin):
@@ -52,7 +56,9 @@ def __init__(self, config: PluginConfig):
"""
super().__init__(config)
- async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult:
+ async def prompt_pre_fetch(
+ self, payload: PromptPrehookPayload, context: PluginContext
+ ) -> PromptPrehookResult:
"""The plugin hook run before a prompt is retrieved and rendered.
Args:
@@ -65,7 +71,9 @@ async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginC
return PromptPrehookResult(continue_processing=True)
- async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult:
+ async def prompt_post_fetch(
+ self, payload: PromptPosthookPayload, context: PluginContext
+ ) -> PromptPosthookResult:
"""Plugin hook run after a prompt is rendered.
Args:
@@ -77,7 +85,9 @@ async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: Plugi
"""
return PromptPosthookResult(continue_processing=True)
- async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult:
+ async def tool_pre_invoke(
+ self, payload: ToolPreInvokePayload, context: PluginContext
+ ) -> ToolPreInvokeResult:
"""Plugin hook run before a tool is invoked.
Args:
@@ -87,11 +97,11 @@ async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginCo
Returns:
The result of the plugin's analysis, including whether the tool can proceed.
"""
- logger.info("tool_pre_invoke....")
+ logger.info("[NemoCheck] Starting tool_pre_invoke")
logger.info(payload)
tool_name = payload.name # ("tool_name", None)
check_nemo_payload = {
- "model": MODEL_NAME,
+ "model": MODEL_NAME, # ideally optional
"messages": [
{
"role": "assistant",
@@ -101,7 +111,9 @@ async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginCo
"type": "function",
"function": {
"name": tool_name,
- "arguments": payload.args.get("tool_args", None),
+ "arguments": payload.args.get(
+ "tool_args", None
+ ),
},
}
],
@@ -109,31 +121,49 @@ async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginCo
],
}
violation = None
- response = requests.post(CHECK_ENDPOINT, headers=headers, json=check_nemo_payload)
+ response = requests.post(
+ CHECK_ENDPOINT, headers=headers, json=check_nemo_payload
+ )
if response.status_code == 200:
data = response.json()
status = data.get("status", "blocked")
logger.debug(f"rails reply:{data}")
if status == "success":
metadata = data.get("rails_status")
- result = ToolPreInvokeResult(continue_processing=True, metadata=metadata)
+ result = ToolPreInvokeResult(
+ continue_processing=True, metadata=metadata
+ )
else:
metadata = data.get("rails_status")
violation = PluginViolation(
- reason=f"Tool Check status:{status}", description="Rails check blocked request", code=f"checkserver_http_status_code:{response.status_code}", details=metadata
+ reason=f"Tool Check status:{status}",
+ description="Rails check blocked request",
+ code=f"checkserver_http_status_code:{response.status_code}",
+ details=metadata,
+ )
+ result = ToolPreInvokeResult(
+ continue_processing=False,
+ violation=violation,
+ metadata=metadata,
)
- result = ToolPreInvokeResult(continue_processing=False, violation=violation, metadata=metadata)
else:
violation = PluginViolation(
- reason="Tool Check Unavailable", description="Tool arguments check server returned error:", code=f"checkserver_http_status_code:{response.status_code}", details={}
+ reason="Tool Check Unavailable",
+ description="Tool arguments check server returned error:",
+ code=f"checkserver_http_status_code:{response.status_code}",
+ details={},
+ )
+ result = ToolPreInvokeResult(
+ continue_processing=False, violation=violation
)
- result = ToolPreInvokeResult(continue_processing=False, violation=violation)
logger.info(response)
return result
- async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult:
+ async def tool_post_invoke(
+ self, payload: ToolPostInvokePayload, context: PluginContext
+ ) -> ToolPostInvokeResult:
"""Plugin hook run after a tool is invoked.
Args:
@@ -143,4 +173,84 @@ async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: Plugin
Returns:
The result of the plugin's analysis, including whether the tool result should proceed.
"""
- return ToolPostInvokeResult(continue_processing=True)
+ logger.info(
+ f"[NemoCheck] Starting tool post invoke hook with payload {payload}"
+ )
+
+ # Extract content from payload.result
+ # payload.result format: {'content': [{'type': 'text', 'text': 'Hello, bob!'}]}
+ result_content = payload.result.get("content", [])
+ tool_name = payload.name
+
+ if not result_content:
+ logger.warning(
+ "[NemoCheck] No content in tool result, skipping check"
+ )
+ return ToolPostInvokeResult(continue_processing=True)
+
+ # Extract text content from the content array
+ # TODO: what to do if there's actually multiple texts?
+ text_content = ""
+ for item in result_content:
+ if item.get("type") == "text":
+ text_content += item.get("text", "")
+
+ # Build NeMo check payload for tool response
+ check_nemo_payload = {
+ "model": MODEL_NAME, # ideally optional
+ "messages": [
+ {"role": "tool", "content": text_content, "name": tool_name}
+ ],
+ }
+
+ logger.debug(
+ f"[NemoCheck] Payload for guardrail check: {check_nemo_payload}"
+ )
+
+ violation = None
+ try:
+ response = requests.post(
+ CHECK_ENDPOINT, headers=headers, json=check_nemo_payload
+ )
+ if response.status_code == 200:
+ data = response.json()
+ status = data.get("status", "blocked")
+ logger.debug(f"[NemoCheck] Rails reply: {data}")
+
+ if status == "success":
+ metadata = data.get("rails_status")
+ result = ToolPostInvokeResult(
+ continue_processing=True, metadata=metadata
+ )
+ else: # blocked
+ metadata = data.get("rails_status")
+ violation = PluginViolation(
+ reason=f"Tool response check status: {status}",
+ description="Rails check blocked tool response",
+ code=f"checkserver_http_status_code:{response.status_code}",
+ details=metadata,
+ )
+ result = ToolPostInvokeResult(
+ continue_processing=False,
+ violation=violation,
+ metadata=metadata,
+ )
+ else:
+ violation = PluginViolation(
+ reason="Tool response check unavailable",
+ description="Tool response check server returned error",
+ code=f"checkserver_http_status_code:{response.status_code}",
+ details={},
+ )
+ result = ToolPostInvokeResult(
+ continue_processing=False, violation=violation
+ )
+
+ logger.info(f"[NemoCheck] Tool post invoke result: {result}")
+ return result
+
+ except Exception as e:
+ logger.error(f"[NemoCheck] Error checking tool response: {e}")
+ return ToolPostInvokeResult(
+ continue_processing=True
+ ) # Fail open on error
diff --git a/src/server.py b/src/server.py
index 3311b9b..2a1a3cb 100644
--- a/src/server.py
+++ b/src/server.py
@@ -60,7 +60,9 @@ async def getToolPreInvokeResponse(body):
"tool_args": body["params"]["arguments"],
"client_session_id": "replaceme",
}
- payload = ToolPreInvokePayload(name=body["params"]["name"], args=payload_args)
+ payload = ToolPreInvokePayload(
+ name=body["params"]["name"], args=payload_args
+ )
# TODO: hard-coded ids
global_context = GlobalContext(request_id="1", server_id="2")
logger.debug(f"**** Invoking Tool Pre Invoke with payload: {payload} ****")
@@ -88,7 +90,8 @@ async def getToolPreInvokeResponse(body):
),
core.HeaderValueOption(
header=core.HeaderValue(
- key="x-mcp-denied", raw_value="True".encode("utf-8")
+ key="x-mcp-denied",
+ raw_value="True".encode("utf-8"),
)
),
],
@@ -103,7 +106,9 @@ async def getToolPreInvokeResponse(body):
body["params"]["arguments"] = result_payload.args["tool_args"]
body_mutation = ep.BodyResponse(
response=ep.CommonResponse(
- body_mutation=ep.BodyMutation(body=json.dumps(body).encode("utf-8"))
+ body_mutation=ep.BodyMutation(
+ body=json.dumps(body).encode("utf-8")
+ )
)
)
else:
@@ -134,11 +139,32 @@ async def getToolPostInvokeResponse(body):
)
logger.info(result)
if not result.continue_processing:
+ error_body = {
+ "jsonrpc": body["jsonrpc"],
+ "id": body["id"],
+ "error": {"code": -32000, "message": "No go - Tool args forbidden"},
+ }
body_resp = ep.ProcessingResponse(
immediate_response=ep.ImmediateResponse(
- # TODO: hard-coded error reason
- status=http_status_pb2.HttpStatus(code=http_status_pb2.Forbidden),
- details="No go",
+ # ok for stream, with error in body
+ status=http_status_pb2.HttpStatus(code=200),
+ headers=ep.HeaderMutation(
+ set_headers=[
+ core.HeaderValueOption(
+ header=core.HeaderValue(
+ key="content-type",
+ raw_value="application/json".encode("utf-8"),
+ )
+ ),
+ core.HeaderValueOption(
+ header=core.HeaderValue(
+ key="x-mcp-denied",
+ raw_value="True".encode("utf-8"),
+ )
+ ),
+ ],
+ ),
+ body=(json.dumps(error_body)).encode("utf-8"),
)
)
else:
@@ -147,7 +173,9 @@ async def getToolPostInvokeResponse(body):
body["result"] = result_payload.result
body_mutation = ep.BodyResponse(
response=ep.CommonResponse(
- body_mutation=ep.BodyMutation(body=json.dumps(body).encode("utf-8"))
+ body_mutation=ep.BodyMutation(
+ body=json.dumps(body).encode("utf-8")
+ )
)
)
else:
@@ -175,7 +203,9 @@ async def getPromptPreFetchResponse(body):
if not result.continue_processing:
body_resp = ep.ProcessingResponse(
immediate_response=ep.ImmediateResponse(
- status=http_status_pb2.HttpStatus(code=http_status_pb2.Forbidden),
+ status=http_status_pb2.HttpStatus(
+ code=http_status_pb2.Forbidden
+ ),
details="No go",
)
)
@@ -184,7 +214,9 @@ async def getPromptPreFetchResponse(body):
body_resp = ep.ProcessingResponse(
request_body=ep.BodyResponse(
response=ep.CommonResponse(
- body_mutation=ep.BodyMutation(body=json.dumps(body).encode("utf-8"))
+ body_mutation=ep.BodyMutation(
+ body=json.dumps(body).encode("utf-8")
+ )
)
)
)
@@ -287,7 +319,9 @@ async def Process(
body = json.loads(text)
if "method" in body and body["method"] == "tools/call":
body_resp = await getToolPreInvokeResponse(body)
- elif "method" in body and body["method"] == "prompts/get":
+ elif (
+ "method" in body and body["method"] == "prompts/get"
+ ):
body_resp = await getPromptPreFetchResponse(body)
else:
body_resp = ep.ProcessingResponse(
@@ -302,7 +336,10 @@ async def Process(
# ----------------------------------------------------------------
# Response Body Processing (MCP Tool Results)
# ----------------------------------------------------------------
- elif request.HasField("response_body") and request.response_body.body:
+ elif (
+ request.HasField("response_body") and request.response_body.body
+ ):
+ logger.info(f"!!!In Process for response body: {request}")
chunk = request.response_body.body
resp_body_buf.extend(chunk)
@@ -312,15 +349,43 @@ async def Process(
except UnicodeDecodeError:
logger.debug("Response body not UTF-8; skipping")
else:
- logger.info(text.split("\n"))
- # find data key
- data = [d for d in text.split("\n") if d.startswith("data:")]
- # logger.info(json.loads(data[0].strip("data:")))
- if data: # List can be empty
- data = json.loads(data[0].strip("data:"))
- # TODO: check for tool call
- if "result" in data and "content" in data["result"]:
- body_resp = await getToolPostInvokeResponse(data)
+ logger.info(f"!!!Text before split: {text.split('\n')}")
+ # Assume streamable HTTP transport, not SSE - TODO: do we need to check?
+ lines = [
+ line.strip()
+ for line in text.split("\n")
+ if line.strip()
+ ]
+
+ if lines:
+ try:
+ # Parse the JSON-RPC response
+ data = json.loads(lines[0])
+ logger.info(f"!!!Parsed response: {data}")
+
+ # Check if this is a tool result response
+ if (
+ "result" in data
+ and "content" in data["result"]
+ ):
+ body_resp = await getToolPostInvokeResponse(
+ data
+ )
+ else:
+ body_resp = ep.ProcessingResponse(
+ response_body=ep.BodyResponse(
+ response=ep.CommonResponse()
+ )
+ )
+ except json.JSONDecodeError as e:
+ logger.error(
+ f"Failed to parse response JSON: {e}"
+ )
+ body_resp = ep.ProcessingResponse(
+ response_body=ep.BodyResponse(
+ response=ep.CommonResponse()
+ )
+ )
else:
body_resp = ep.ProcessingResponse(
response_body=ep.BodyResponse(
From bc7172fbcb1c6033f89d3ed41aa98b0d5b0434a0 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Tue, 10 Feb 2026 16:44:23 -0700
Subject: [PATCH 02/27] :wrench::art: Update line length and lint
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
.pre-commit-config.yaml | 3 +-
plugins/examples/nemo/nemo_wrapper_plugin.py | 16 +++++++----
plugins/examples/nemocheck/tests/test_all.py | 28 ++++++++++++++-----
.../nemocheck/tests/test_nemocheck.py | 4 ++-
4 files changed, 36 insertions(+), 15 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a2cd02a..7e76baf 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,6 +4,7 @@ repos:
hooks:
# Run the linter.
- id: ruff
- args: [ --fix ]
+ args: [ --fix, --line-length=80 ]
# Run the formatter.
- id: ruff-format
+ args: [ --line-length=80 ]
diff --git a/plugins/examples/nemo/nemo_wrapper_plugin.py b/plugins/examples/nemo/nemo_wrapper_plugin.py
index 047c46f..f7c1a06 100644
--- a/plugins/examples/nemo/nemo_wrapper_plugin.py
+++ b/plugins/examples/nemo/nemo_wrapper_plugin.py
@@ -62,20 +62,24 @@ async def tool_pre_invoke(
rails_response = await self._rails.generate_async(
messages=[{"role": "user", "content": payload_args}]
)
- except (
- asyncio.CancelledError
- ): # asyncio.exceptions.CancelledError is thrown by nemo, need to catch
- logging.exception("An error occurred in the nemo plugin except block:")
+ except asyncio.CancelledError: # asyncio.exceptions.CancelledError is thrown by nemo, need to catch
+ logging.exception(
+ "An error occurred in the nemo plugin except block:"
+ )
finally:
logger.warning("[NemoWrapperPlugin] Async rails executed")
logger.warning(rails_response)
if rails_response and "PII detected" in rails_response["content"]:
- logger.warning("[NemoWrapperPlugin] PII detected, stopping processing")
+ logger.warning(
+ "[NemoWrapperPlugin] PII detected, stopping processing"
+ )
return ToolPreInvokeResult(
modified_payload=payload, continue_processing=False
)
logger.warning("[NemoWrapperPlugin] No PII detected, continuing")
- return ToolPreInvokeResult(modified_payload=payload, continue_processing=True)
+ return ToolPreInvokeResult(
+ modified_payload=payload, continue_processing=True
+ )
async def tool_post_invoke(
self, payload: ToolPostInvokePayload, context: PluginContext
diff --git a/plugins/examples/nemocheck/tests/test_all.py b/plugins/examples/nemocheck/tests/test_all.py
index 85d1c22..f2263e5 100644
--- a/plugins/examples/nemocheck/tests/test_all.py
+++ b/plugins/examples/nemocheck/tests/test_all.py
@@ -33,9 +33,13 @@ def plugin_manager():
async def test_prompt_pre_hook(plugin_manager: PluginManager):
"""Test prompt pre hook across all registered plugins."""
# Customize payload for testing
- payload = PromptPrehookPayload(prompt_id="test_prompt", args={"arg0": "This is an argument"})
+ payload = PromptPrehookPayload(
+ prompt_id="test_prompt", args={"arg0": "This is an argument"}
+ )
global_context = GlobalContext(request_id="1")
- result, _ = await plugin_manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context)
+ result, _ = await plugin_manager.invoke_hook(
+ PromptHookType.PROMPT_PRE_FETCH, payload, global_context
+ )
# Assert expected behaviors
assert result.continue_processing
@@ -44,11 +48,17 @@ async def test_prompt_pre_hook(plugin_manager: PluginManager):
async def test_prompt_post_hook(plugin_manager: PluginManager):
"""Test prompt post hook across all registered plugins."""
# Customize payload for testing
- message = Message(content=TextContent(type="text", text="prompt"), role=Role.USER)
+ message = Message(
+ content=TextContent(type="text", text="prompt"), role=Role.USER
+ )
prompt_result = PromptResult(messages=[message])
- payload = PromptPosthookPayload(prompt_id="test_prompt", result=prompt_result)
+ payload = PromptPosthookPayload(
+ prompt_id="test_prompt", result=prompt_result
+ )
global_context = GlobalContext(request_id="1")
- result, _ = await plugin_manager.invoke_hook(PromptHookType.PROMPT_POST_FETCH, payload, global_context)
+ result, _ = await plugin_manager.invoke_hook(
+ PromptHookType.PROMPT_POST_FETCH, payload, global_context
+ )
# Assert expected behaviors
assert result.continue_processing
@@ -57,8 +67,12 @@ async def test_prompt_post_hook(plugin_manager: PluginManager):
async def test_tool_post_hook(plugin_manager: PluginManager):
"""Test tool post hook across all registered plugins."""
# Customize payload for testing
- payload = ToolPostInvokePayload(name="test_tool", result={"output0": "output value"})
+ payload = ToolPostInvokePayload(
+ name="test_tool", result={"output0": "output value"}
+ )
global_context = GlobalContext(request_id="1")
- result, _ = await plugin_manager.invoke_hook(ToolHookType.TOOL_POST_INVOKE, payload, global_context)
+ result, _ = await plugin_manager.invoke_hook(
+ ToolHookType.TOOL_POST_INVOKE, payload, global_context
+ )
# Assert expected behaviors
assert result.continue_processing
diff --git a/plugins/examples/nemocheck/tests/test_nemocheck.py b/plugins/examples/nemocheck/tests/test_nemocheck.py
index 4321c51..6ee310e 100644
--- a/plugins/examples/nemocheck/tests/test_nemocheck.py
+++ b/plugins/examples/nemocheck/tests/test_nemocheck.py
@@ -25,7 +25,9 @@ async def test_nemocheck():
plugin = NemoCheck(config)
# Test your plugin logic
- payload = PromptPrehookPayload(prompt_id="test_prompt", args={"arg0": "This is an argument"})
+ payload = PromptPrehookPayload(
+ prompt_id="test_prompt", args={"arg0": "This is an argument"}
+ )
context = GlobalContext(request_id="1")
result = await plugin.prompt_pre_fetch(payload, context)
assert result.continue_processing
From 51654236dfa36339c4b5a7fc084c5459be7a7422 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Tue, 10 Feb 2026 16:45:54 -0700
Subject: [PATCH 03/27] :rewind: Revert port change
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
plugins/examples/nemocheck/k8deploy/deploy.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/plugins/examples/nemocheck/k8deploy/deploy.yaml b/plugins/examples/nemocheck/k8deploy/deploy.yaml
index 0971cb8..4ae0703 100644
--- a/plugins/examples/nemocheck/k8deploy/deploy.yaml
+++ b/plugins/examples/nemocheck/k8deploy/deploy.yaml
@@ -34,7 +34,7 @@ spec:
- name: MODEL_NAME # Currently only for logging.
value: "meta-llama/llama-3-3-70b-instruct"
- name: CHECK_ENDPOINT
- value: "http://nemo-guardrails-service:50053/v1/guardrail/checks"
+ value: "http://nemo-guardrails-service:8000/v1/guardrail/checks"
- name: PLUGINS_SERVER_HOST
value: "0.0.0.0"
- name: PLUGINS_ENABLED
From 188e8ecc02a0775a8077eaf6f0794bef5d9acc96 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Tue, 10 Feb 2026 16:53:44 -0700
Subject: [PATCH 04/27] :sparkles: SSE format in tools
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
src/server.py | 71 +++++++++++++++++++++++++++++----------------------
1 file changed, 40 insertions(+), 31 deletions(-)
diff --git a/src/server.py b/src/server.py
index 2a1a3cb..af28cc5 100644
--- a/src/server.py
+++ b/src/server.py
@@ -350,37 +350,46 @@ async def Process(
logger.debug("Response body not UTF-8; skipping")
else:
logger.info(f"!!!Text before split: {text.split('\n')}")
- # Assume streamable HTTP transport, not SSE - TODO: do we need to check?
- lines = [
- line.strip()
- for line in text.split("\n")
- if line.strip()
- ]
-
- if lines:
- try:
- # Parse the JSON-RPC response
- data = json.loads(lines[0])
- logger.info(f"!!!Parsed response: {data}")
-
- # Check if this is a tool result response
- if (
- "result" in data
- and "content" in data["result"]
- ):
- body_resp = await getToolPostInvokeResponse(
- data
- )
- else:
- body_resp = ep.ProcessingResponse(
- response_body=ep.BodyResponse(
- response=ep.CommonResponse()
- )
- )
- except json.JSONDecodeError as e:
- logger.error(
- f"Failed to parse response JSON: {e}"
- )
+
+ # Handle both SSE format and plain JSON-RPC format
+ data = None
+
+ # Check if this is SSE format (starts with "event:" or "data:")
+ if text.strip().startswith(("event:", "data:")):
+ # Parse SSE format
+ lines = text.split("\n")
+ for line in lines:
+ line = line.strip()
+ if line.startswith("data:"):
+ json_str = line[5:].strip() # Remove "data:" prefix
+ try:
+ data = json.loads(json_str)
+ break
+ except json.JSONDecodeError:
+ continue
+ else:
+ # Parse plain JSON-RPC format
+ lines = [
+ line.strip()
+ for line in text.split("\n")
+ if line.strip()
+ ]
+ if lines:
+ try:
+ data = json.loads(lines[0])
+ except json.JSONDecodeError as e:
+ logger.error(f"Failed to parse JSON: {e}")
+
+ if data:
+ logger.info(f"!!!Parsed response: {data}")
+
+ # Check if this is a tool result response
+ if (
+ "result" in data
+ and "content" in data["result"]
+ ):
+ body_resp = await getToolPostInvokeResponse(data)
+ else:
body_resp = ep.ProcessingResponse(
response_body=ep.BodyResponse(
response=ep.CommonResponse()
From 7cfad9eb33b675a88705a1aa0dc49a2a6d45305f Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Wed, 11 Feb 2026 07:42:00 -0700
Subject: [PATCH 05/27] :loud_sound: Update tool response error message and
debug logs
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
src/server.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/server.py b/src/server.py
index af28cc5..afe6368 100644
--- a/src/server.py
+++ b/src/server.py
@@ -142,7 +142,7 @@ async def getToolPostInvokeResponse(body):
error_body = {
"jsonrpc": body["jsonrpc"],
"id": body["id"],
- "error": {"code": -32000, "message": "No go - Tool args forbidden"},
+ "error": {"code": -32000, "message": "Tool response forbidden"},
}
body_resp = ep.ProcessingResponse(
immediate_response=ep.ImmediateResponse(
@@ -339,7 +339,7 @@ async def Process(
elif (
request.HasField("response_body") and request.response_body.body
):
- logger.info(f"!!!In Process for response body: {request}")
+ logger.debug(f"Processing response body: {request}")
chunk = request.response_body.body
resp_body_buf.extend(chunk)
@@ -349,7 +349,7 @@ async def Process(
except UnicodeDecodeError:
logger.debug("Response body not UTF-8; skipping")
else:
- logger.info(f"!!!Text before split: {text.split('\n')}")
+ logger.debug(f"Response body text: {text.split('\n')}")
# Handle both SSE format and plain JSON-RPC format
data = None
@@ -381,7 +381,7 @@ async def Process(
logger.error(f"Failed to parse JSON: {e}")
if data:
- logger.info(f"!!!Parsed response: {data}")
+ logger.debug(f"Parsed response data: {data}")
# Check if this is a tool result response
if (
From 67dccea5838ce94a9254ff7bf819b7bcb09253f8 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Wed, 11 Feb 2026 09:47:28 -0700
Subject: [PATCH 06/27] :goal_net: Buffer intermediate response chunks
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
src/server.py | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/src/server.py b/src/server.py
index afe6368..b175825 100644
--- a/src/server.py
+++ b/src/server.py
@@ -344,6 +344,7 @@ async def Process(
resp_body_buf.extend(chunk)
if getattr(request.response_body, "end_of_stream", False):
+ # End of stream reached - process complete buffered response
try:
text = resp_body_buf.decode("utf-8")
except UnicodeDecodeError:
@@ -403,6 +404,16 @@ async def Process(
)
yield body_resp
resp_body_buf.clear()
+ else:
+ # Intermediate chunk - buffer only, don't process yet
+ # TODO: how should this be handled?
+ logger.debug(f"Buffering intermediate chunk ({len(chunk)} bytes), waiting for end_of_stream")
+ # Yield empty response to acknowledge chunk receipt
+ yield ep.ProcessingResponse(
+ response_body=ep.BodyResponse(
+ response=ep.CommonResponse()
+ )
+ )
# ----------------------------------------------------------------
# Response Body Processing (No body field)
# ----------------------------------------------------------------
From 0b3e4b16b8ea8a21e0b5cc83307cfaeb6e469424 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Wed, 11 Feb 2026 10:39:16 -0700
Subject: [PATCH 07/27] :goal_net: Handle empty chunk cases with EOS
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
src/server.py | 198 +++++++++++++++++++++++++++-----------------------
1 file changed, 106 insertions(+), 92 deletions(-)
diff --git a/src/server.py b/src/server.py
index b175825..88fc7a7 100644
--- a/src/server.py
+++ b/src/server.py
@@ -137,34 +137,37 @@ async def getToolPostInvokeResponse(body):
result, _ = await manager.invoke_hook(
ToolHookType.TOOL_POST_INVOKE, payload, global_context=global_context
)
- logger.info(result)
+ logger.debug(f"**** Tool Post Invoke result {result}")
if not result.continue_processing:
+ # Build error response to replace the tool result
error_body = {
"jsonrpc": body["jsonrpc"],
"id": body["id"],
"error": {"code": -32000, "message": "Tool response forbidden"},
}
body_resp = ep.ProcessingResponse(
- immediate_response=ep.ImmediateResponse(
- # ok for stream, with error in body
- status=http_status_pb2.HttpStatus(code=200),
- headers=ep.HeaderMutation(
- set_headers=[
- core.HeaderValueOption(
- header=core.HeaderValue(
- key="content-type",
- raw_value="application/json".encode("utf-8"),
- )
- ),
- core.HeaderValueOption(
- header=core.HeaderValue(
- key="x-mcp-denied",
- raw_value="True".encode("utf-8"),
- )
- ),
- ],
- ),
- body=(json.dumps(error_body)).encode("utf-8"),
+ response_body=ep.BodyResponse(
+ response=ep.CommonResponse(
+ header_mutation=ep.HeaderMutation(
+ set_headers=[
+ core.HeaderValueOption(
+ header=core.HeaderValue(
+ key="content-type",
+ raw_value="application/json".encode("utf-8"),
+ )
+ ),
+ core.HeaderValueOption(
+ header=core.HeaderValue(
+ key="x-mcp-denied",
+ raw_value="True".encode("utf-8"),
+ )
+ ),
+ ],
+ ),
+ body_mutation=ep.BodyMutation(
+ body=(json.dumps(error_body)).encode("utf-8")
+ ),
+ )
)
)
else:
@@ -180,7 +183,7 @@ async def getToolPostInvokeResponse(body):
)
else:
body_mutation = ep.BodyResponse(response=ep.CommonResponse())
- body_resp = ep.ProcessingResponse(request_body=body_mutation)
+ body_resp = ep.ProcessingResponse(response_body=body_mutation)
return body_resp
@@ -336,94 +339,105 @@ async def Process(
# ----------------------------------------------------------------
# Response Body Processing (MCP Tool Results)
# ----------------------------------------------------------------
- elif (
- request.HasField("response_body") and request.response_body.body
- ):
+ elif request.HasField("response_body"):
logger.debug(f"Processing response body: {request}")
- chunk = request.response_body.body
- resp_body_buf.extend(chunk)
+ # Buffer content if present in this chunk
+ if request.response_body.body:
+ chunk = request.response_body.body
+ resp_body_buf.extend(chunk)
+ logger.debug(f"Buffered chunk ({len(chunk)} bytes)")
+
+ # Check for end of stream (regardless of whether this chunk has content)
if getattr(request.response_body, "end_of_stream", False):
- # End of stream reached - process complete buffered response
- try:
- text = resp_body_buf.decode("utf-8")
- except UnicodeDecodeError:
- logger.debug("Response body not UTF-8; skipping")
- else:
- logger.debug(f"Response body text: {text.split('\n')}")
-
- # Handle both SSE format and plain JSON-RPC format
- data = None
-
- # Check if this is SSE format (starts with "event:" or "data:")
- if text.strip().startswith(("event:", "data:")):
- # Parse SSE format
- lines = text.split("\n")
- for line in lines:
- line = line.strip()
- if line.startswith("data:"):
- json_str = line[5:].strip() # Remove "data:" prefix
- try:
- data = json.loads(json_str)
- break
- except json.JSONDecodeError:
- continue
+ logger.debug("End of stream reached, processing complete buffered response")
+
+ # Process buffered content if any
+ if resp_body_buf:
+ try:
+ text = resp_body_buf.decode("utf-8")
+ except UnicodeDecodeError:
+ logger.debug("Response body not UTF-8; skipping")
+ body_resp = ep.ProcessingResponse(
+ response_body=ep.BodyResponse(
+ response=ep.CommonResponse()
+ )
+ )
else:
- # Parse plain JSON-RPC format
- lines = [
- line.strip()
- for line in text.split("\n")
- if line.strip()
- ]
- if lines:
- try:
- data = json.loads(lines[0])
- except json.JSONDecodeError as e:
- logger.error(f"Failed to parse JSON: {e}")
-
- if data:
- logger.debug(f"Parsed response data: {data}")
-
- # Check if this is a tool result response
- if (
- "result" in data
- and "content" in data["result"]
- ):
- body_resp = await getToolPostInvokeResponse(data)
+ logger.debug(f"Response body text: {text.split('\n')}")
+
+ # Handle both SSE format and plain JSON-RPC format
+ data = None
+
+ # Check if this is SSE format (starts with "event:" or "data:")
+ if text.strip().startswith(("event:", "data:")):
+ # Parse SSE format
+ lines = text.split("\n")
+ for line in lines:
+ line = line.strip()
+ if line.startswith("data:"):
+ json_str = line[5:].strip() # Remove "data:" prefix
+ logger.debug(f"Extracted JSON from SSE: {json_str}")
+ try:
+ data = json.loads(json_str)
+ break
+ except json.JSONDecodeError:
+ continue
+ else:
+ # Parse plain JSON-RPC format
+ lines = [
+ line.strip()
+ for line in text.split("\n")
+ if line.strip()
+ ]
+ if lines:
+ try:
+ data = json.loads(lines[0])
+ except json.JSONDecodeError as e:
+ logger.error(f"Failed to parse JSON: {e}")
+
+ if data:
+ logger.debug(f"Parsed response data: {data}")
+
+ # Check if this is a tool result response
+ if (
+ "result" in data
+ and "content" in data["result"]
+ ):
+ logger.info("Invoking tool post-invoke hook")
+ body_resp = await getToolPostInvokeResponse(data)
+ else:
+ body_resp = ep.ProcessingResponse(
+ response_body=ep.BodyResponse(
+ response=ep.CommonResponse()
+ )
+ )
else:
+ logger.warning("No data parsed from response body")
body_resp = ep.ProcessingResponse(
response_body=ep.BodyResponse(
response=ep.CommonResponse()
)
)
- else:
- body_resp = ep.ProcessingResponse(
- response_body=ep.BodyResponse(
- response=ep.CommonResponse()
- )
+ else:
+ # Empty buffer at end of stream
+ logger.debug("End of stream with empty buffer")
+ body_resp = ep.ProcessingResponse(
+ response_body=ep.BodyResponse(
+ response=ep.CommonResponse()
)
- yield body_resp
+ )
+
+ yield body_resp
resp_body_buf.clear()
else:
- # Intermediate chunk - buffer only, don't process yet
- # TODO: how should this be handled?
- logger.debug(f"Buffering intermediate chunk ({len(chunk)} bytes), waiting for end_of_stream")
- # Yield empty response to acknowledge chunk receipt
+ # Intermediate chunk - acknowledge but don't process yet
+ logger.debug(f"Buffering intermediate chunk, waiting for end_of_stream")
yield ep.ProcessingResponse(
response_body=ep.BodyResponse(
response=ep.CommonResponse()
)
)
- # ----------------------------------------------------------------
- # Response Body Processing (No body field)
- # ----------------------------------------------------------------
- elif request.HasField("response_body"):
- logger.warning("On Response, no body.")
- logger.warning(request)
- body_resp = ep.ProcessingResponse(
- response_body=ep.BodyResponse(response=ep.CommonResponse())
- )
- yield body_resp
else:
# Unhandled request types
From e33d36e0151de8b4a32b06a6592e6d22b52178ca Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Wed, 11 Feb 2026 10:58:35 -0700
Subject: [PATCH 08/27] :white_check_mark: Add nemocheck plugin tool pre and
tool post invoke test cases
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
.github/workflows/ci.yaml | 7 +-
.../nemocheck/tests/test_nemocheck.py | 173 +++++++++++++++++-
2 files changed, 169 insertions(+), 11 deletions(-)
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 7536ae2..9c50347 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -47,8 +47,9 @@ jobs:
run: |
echo "Running nemocheck ext plugin tests.."
uv sync --all-groups
+
+ # Tests
- name: Run nemocheck external plugin tests
working-directory: ./plugins/examples/nemocheck
- run: uv run pytest tests
-
- # - name: Test
+ run: uv run pytest tests
+
diff --git a/plugins/examples/nemocheck/tests/test_nemocheck.py b/plugins/examples/nemocheck/tests/test_nemocheck.py
index 6ee310e..10d7341 100644
--- a/plugins/examples/nemocheck/tests/test_nemocheck.py
+++ b/plugins/examples/nemocheck/tests/test_nemocheck.py
@@ -1,5 +1,8 @@
"""Tests for plugin."""
+# Standard
+from unittest.mock import Mock, patch
+
# Third-Party
import pytest
@@ -9,25 +12,179 @@
PluginConfig,
GlobalContext,
PromptPrehookPayload,
+ ToolPostInvokePayload,
+ ToolPreInvokePayload,
)
-@pytest.mark.asyncio
-async def test_nemocheck():
- """Test plugin prompt prefetch hook."""
+@pytest.fixture
+def plugin():
+ """Create a NemoCheck plugin instance."""
config = PluginConfig(
name="test",
kind="nemocheck.NemoCheck",
- hooks=["prompt_pre_fetch"],
- config={"setting_one": "test_value"},
+ hooks=["prompt_pre_fetch", "tool_pre_invoke", "tool_post_invoke"],
+ config={},
)
+ return NemoCheck(config)
+
+
+@pytest.fixture
+def context():
+ """Create a GlobalContext instance."""
+ return GlobalContext(request_id="1")
+
+
+def mock_http_response(status_code, response_data=None):
+ """Helper to create mock HTTP responses."""
+ mock_response = Mock()
+ mock_response.status_code = status_code
+ if response_data:
+ mock_response.json.return_value = response_data
+ return mock_response
- plugin = NemoCheck(config)
- # Test your plugin logic
+@pytest.mark.asyncio
+async def test_prompt_pre_fetch(plugin, context):
+ """Test plugin prompt prefetch hook."""
payload = PromptPrehookPayload(
prompt_id="test_prompt", args={"arg0": "This is an argument"}
)
- context = GlobalContext(request_id="1")
result = await plugin.prompt_pre_fetch(payload, context)
assert result.continue_processing
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "status_code,response_data,expected_continue,has_violation",
+ [
+ (200, {"status": "success", "rails_status": {"detect senstitive data": {"status": "success"}}}, True, False),
+ (200, {"status": "blocked", "rails_status": {"detect hap": {"status": "blocked"}}}, False, True),
+ (503, None, False, True),
+ ],
+)
+async def test_tool_pre_invoke_scenarios(
+ plugin, context, status_code, response_data, expected_continue, has_violation
+):
+ """Test tool_pre_invoke with various scenarios."""
+ payload = ToolPreInvokePayload(
+ name="test_tool",
+ args={"tool_args": '{"param": "value"}'},
+ )
+
+ with patch(
+ "nemocheck.plugin.requests.post",
+ return_value=mock_http_response(status_code, response_data),
+ ):
+ result = await plugin.tool_pre_invoke(payload, context)
+
+ assert result.continue_processing == expected_continue
+ assert (result.violation is not None) == has_violation
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "status_code,response_data,expected_continue,has_violation",
+ [
+ (200, {"status": "success", "rails_status": {"detect senstitive data": {"status": "success"}}}, True, False),
+ (200, {"status": "blocked", "rails_status": {"detect hap": {"status": "blocked"}}}, False, True),
+ (500, None, False, True),
+ ],
+)
+async def test_tool_post_invoke_http_scenarios(
+ plugin, context, status_code, response_data, expected_continue, has_violation
+):
+ """Test tool_post_invoke with various HTTP response scenarios."""
+ payload = ToolPostInvokePayload(
+ name="test_tool",
+ result={"content": [{"type": "text", "text": "Test content"}]},
+ )
+
+ with patch(
+ "nemocheck.plugin.requests.post",
+ return_value=mock_http_response(status_code, response_data),
+ ):
+ result = await plugin.tool_post_invoke(payload, context)
+
+ assert result.continue_processing == expected_continue
+ assert (result.violation is not None) == has_violation
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "result_data,should_continue",
+ [
+ ({"content": []}, True), # Empty content
+ ({"output": "value"}, True), # No content key
+ ],
+)
+async def test_tool_post_invoke_passthrough_content_cases(plugin, context, result_data, should_continue):
+ """Test tool_post_invoke no/empty content cases that do not flag."""
+ payload = ToolPostInvokePayload(name="test_tool", result=result_data)
+ result = await plugin.tool_post_invoke(payload, context)
+ assert result.continue_processing == should_continue
+ assert result.violation is None
+
+
+@pytest.mark.asyncio
+async def test_tool_post_invoke_concatenates_text(plugin, context):
+ """Test tool_post_invoke concatenates multiple text items."""
+ payload = ToolPostInvokePayload(
+ name="test_tool",
+ result={
+ "content": [
+ {"type": "text", "text": "First. "},
+ {"type": "text", "text": "Second."},
+ ]
+ },
+ )
+
+ with patch(
+ "nemocheck.plugin.requests.post",
+ return_value=mock_http_response(200, {"status": "success", "rails_status": {}}),
+ ) as mock_post:
+ result = await plugin.tool_post_invoke(payload, context)
+
+ assert result.continue_processing
+ sent_content = mock_post.call_args[1]["json"]["messages"][0]["content"]
+ assert sent_content == "First. Second."
+
+
+@pytest.mark.asyncio
+async def test_tool_post_invoke_filters_non_text(plugin, context):
+ """Test tool_post_invoke filters non-text content."""
+ payload = ToolPostInvokePayload(
+ name="test_tool",
+ result={
+ "content": [
+ {"type": "image", "url": "http://example.com/img.png"},
+ {"type": "text", "text": "Text only"},
+ ]
+ },
+ )
+
+ with patch(
+ "nemocheck.plugin.requests.post",
+ return_value=mock_http_response(200, {"status": "success", "rails_status": {}}),
+ ) as mock_post:
+ result = await plugin.tool_post_invoke(payload, context)
+
+ assert result.continue_processing
+ sent_content = mock_post.call_args[1]["json"]["messages"][0]["content"]
+ assert sent_content == "Text only"
+
+
+@pytest.mark.asyncio
+async def test_tool_post_invoke_fails_open_on_exception(plugin, context):
+ """Test tool_post_invoke fails open on exceptions."""
+ payload = ToolPostInvokePayload(
+ name="test_tool",
+ result={"content": [{"type": "text", "text": "content"}]},
+ )
+
+ with patch("nemocheck.plugin.requests.post", side_effect=Exception("Network error")):
+ result = await plugin.tool_post_invoke(payload, context)
+
+ assert result.continue_processing
+ assert result.violation is None
+
From f8807150425c8b088d97a7bf86e7c59e7002a23e Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Wed, 11 Feb 2026 11:09:24 -0700
Subject: [PATCH 09/27] :white_check_mark: Add initial server tests
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
.github/workflows/ci.yaml | 13 +-
.../nemocheck/tests/test_nemocheck.py | 75 +++++-
src/__init__.py | 0
src/server.py | 39 ++-
tests/__init__.py | 0
tests/pytest.ini | 11 +
tests/test_server.py | 232 ++++++++++++++++++
7 files changed, 347 insertions(+), 23 deletions(-)
create mode 100644 src/__init__.py
create mode 100644 tests/__init__.py
create mode 100644 tests/pytest.ini
create mode 100644 tests/test_server.py
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 9c50347..b3937ba 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -42,14 +42,23 @@ jobs:
git diff --stat
exit 1
}
+
+ # Plugin tests
- name: Install nemocheck external plugin dependencies
working-directory: ./plugins/examples/nemocheck
run: |
echo "Running nemocheck ext plugin tests.."
uv sync --all-groups
-
- # Tests
- name: Run nemocheck external plugin tests
working-directory: ./plugins/examples/nemocheck
run: uv run pytest tests
+ # Server tests
+ - name: Install server test dependencies
+ run: |
+ echo "Installing pytest and dependencies for server tests..."
+ pip install pytest pytest-asyncio
+ - name: Run server unit tests
+ run: |
+ echo "Running server unit tests..."
+ uv run pytest tests
diff --git a/plugins/examples/nemocheck/tests/test_nemocheck.py b/plugins/examples/nemocheck/tests/test_nemocheck.py
index 10d7341..a0d59b6 100644
--- a/plugins/examples/nemocheck/tests/test_nemocheck.py
+++ b/plugins/examples/nemocheck/tests/test_nemocheck.py
@@ -58,13 +58,36 @@ async def test_prompt_pre_fetch(plugin, context):
@pytest.mark.parametrize(
"status_code,response_data,expected_continue,has_violation",
[
- (200, {"status": "success", "rails_status": {"detect senstitive data": {"status": "success"}}}, True, False),
- (200, {"status": "blocked", "rails_status": {"detect hap": {"status": "blocked"}}}, False, True),
+ (
+ 200,
+ {
+ "status": "success",
+ "rails_status": {
+ "detect senstitive data": {"status": "success"}
+ },
+ },
+ True,
+ False,
+ ),
+ (
+ 200,
+ {
+ "status": "blocked",
+ "rails_status": {"detect hap": {"status": "blocked"}},
+ },
+ False,
+ True,
+ ),
(503, None, False, True),
],
)
async def test_tool_pre_invoke_scenarios(
- plugin, context, status_code, response_data, expected_continue, has_violation
+ plugin,
+ context,
+ status_code,
+ response_data,
+ expected_continue,
+ has_violation,
):
"""Test tool_pre_invoke with various scenarios."""
payload = ToolPreInvokePayload(
@@ -86,13 +109,36 @@ async def test_tool_pre_invoke_scenarios(
@pytest.mark.parametrize(
"status_code,response_data,expected_continue,has_violation",
[
- (200, {"status": "success", "rails_status": {"detect senstitive data": {"status": "success"}}}, True, False),
- (200, {"status": "blocked", "rails_status": {"detect hap": {"status": "blocked"}}}, False, True),
+ (
+ 200,
+ {
+ "status": "success",
+ "rails_status": {
+ "detect senstitive data": {"status": "success"}
+ },
+ },
+ True,
+ False,
+ ),
+ (
+ 200,
+ {
+ "status": "blocked",
+ "rails_status": {"detect hap": {"status": "blocked"}},
+ },
+ False,
+ True,
+ ),
(500, None, False, True),
],
)
async def test_tool_post_invoke_http_scenarios(
- plugin, context, status_code, response_data, expected_continue, has_violation
+ plugin,
+ context,
+ status_code,
+ response_data,
+ expected_continue,
+ has_violation,
):
"""Test tool_post_invoke with various HTTP response scenarios."""
payload = ToolPostInvokePayload(
@@ -118,7 +164,9 @@ async def test_tool_post_invoke_http_scenarios(
({"output": "value"}, True), # No content key
],
)
-async def test_tool_post_invoke_passthrough_content_cases(plugin, context, result_data, should_continue):
+async def test_tool_post_invoke_passthrough_content_cases(
+ plugin, context, result_data, should_continue
+):
"""Test tool_post_invoke no/empty content cases that do not flag."""
payload = ToolPostInvokePayload(name="test_tool", result=result_data)
result = await plugin.tool_post_invoke(payload, context)
@@ -141,7 +189,9 @@ async def test_tool_post_invoke_concatenates_text(plugin, context):
with patch(
"nemocheck.plugin.requests.post",
- return_value=mock_http_response(200, {"status": "success", "rails_status": {}}),
+ return_value=mock_http_response(
+ 200, {"status": "success", "rails_status": {}}
+ ),
) as mock_post:
result = await plugin.tool_post_invoke(payload, context)
@@ -165,7 +215,9 @@ async def test_tool_post_invoke_filters_non_text(plugin, context):
with patch(
"nemocheck.plugin.requests.post",
- return_value=mock_http_response(200, {"status": "success", "rails_status": {}}),
+ return_value=mock_http_response(
+ 200, {"status": "success", "rails_status": {}}
+ ),
) as mock_post:
result = await plugin.tool_post_invoke(payload, context)
@@ -182,9 +234,10 @@ async def test_tool_post_invoke_fails_open_on_exception(plugin, context):
result={"content": [{"type": "text", "text": "content"}]},
)
- with patch("nemocheck.plugin.requests.post", side_effect=Exception("Network error")):
+ with patch(
+ "nemocheck.plugin.requests.post", side_effect=Exception("Network error")
+ ):
result = await plugin.tool_post_invoke(payload, context)
assert result.continue_processing
assert result.violation is None
-
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/server.py b/src/server.py
index 88fc7a7..4dac2aa 100644
--- a/src/server.py
+++ b/src/server.py
@@ -153,7 +153,9 @@ async def getToolPostInvokeResponse(body):
core.HeaderValueOption(
header=core.HeaderValue(
key="content-type",
- raw_value="application/json".encode("utf-8"),
+ raw_value="application/json".encode(
+ "utf-8"
+ ),
)
),
core.HeaderValueOption(
@@ -350,7 +352,9 @@ async def Process(
# Check for end of stream (regardless of whether this chunk has content)
if getattr(request.response_body, "end_of_stream", False):
- logger.debug("End of stream reached, processing complete buffered response")
+ logger.debug(
+ "End of stream reached, processing complete buffered response"
+ )
# Process buffered content if any
if resp_body_buf:
@@ -364,7 +368,8 @@ async def Process(
)
)
else:
- logger.debug(f"Response body text: {text.split('\n')}")
+ lines = text.split("\n")
+ logger.debug(f"Response body text: {lines}")
# Handle both SSE format and plain JSON-RPC format
data = None
@@ -376,8 +381,12 @@ async def Process(
for line in lines:
line = line.strip()
if line.startswith("data:"):
- json_str = line[5:].strip() # Remove "data:" prefix
- logger.debug(f"Extracted JSON from SSE: {json_str}")
+ json_str = line[
+ 5:
+ ].strip() # Remove "data:" prefix
+ logger.debug(
+ f"Extracted JSON from SSE: {json_str}"
+ )
try:
data = json.loads(json_str)
break
@@ -394,7 +403,9 @@ async def Process(
try:
data = json.loads(lines[0])
except json.JSONDecodeError as e:
- logger.error(f"Failed to parse JSON: {e}")
+ logger.error(
+ f"Failed to parse JSON: {e}"
+ )
if data:
logger.debug(f"Parsed response data: {data}")
@@ -404,8 +415,12 @@ async def Process(
"result" in data
and "content" in data["result"]
):
- logger.info("Invoking tool post-invoke hook")
- body_resp = await getToolPostInvokeResponse(data)
+ logger.info(
+ "Invoking tool post-invoke hook"
+ )
+ body_resp = await getToolPostInvokeResponse(
+ data
+ )
else:
body_resp = ep.ProcessingResponse(
response_body=ep.BodyResponse(
@@ -413,7 +428,9 @@ async def Process(
)
)
else:
- logger.warning("No data parsed from response body")
+ logger.warning(
+ "No data parsed from response body"
+ )
body_resp = ep.ProcessingResponse(
response_body=ep.BodyResponse(
response=ep.CommonResponse()
@@ -432,7 +449,9 @@ async def Process(
resp_body_buf.clear()
else:
# Intermediate chunk - acknowledge but don't process yet
- logger.debug(f"Buffering intermediate chunk, waiting for end_of_stream")
+ logger.debug(
+ "Buffering intermediate chunk, waiting for end_of_stream"
+ )
yield ep.ProcessingResponse(
response_body=ep.BodyResponse(
response=ep.CommonResponse()
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/pytest.ini b/tests/pytest.ini
new file mode 100644
index 0000000..9a43301
--- /dev/null
+++ b/tests/pytest.ini
@@ -0,0 +1,11 @@
+[pytest]
+log_cli = false
+log_cli_level = INFO
+log_cli_format = %(asctime)s [%(module)s] [%(levelname)s] %(message)s
+log_cli_date_format = %Y-%m-%d %H:%M:%S
+log_level = INFO
+log_format = %(asctime)s [%(module)s] [%(levelname)s] %(message)s
+log_date_format = %Y-%m-%d %H:%M:%S
+pythonpath = . src
+filterwarnings =
+ ignore::DeprecationWarning
\ No newline at end of file
diff --git a/tests/test_server.py b/tests/test_server.py
new file mode 100644
index 0000000..0a5f033
--- /dev/null
+++ b/tests/test_server.py
@@ -0,0 +1,232 @@
+"""Unit tests for ext-proc server functions
+
+These tests use dynamic import and mocking to avoid proto dependencies.
+"""
+
+# Standard
+from unittest.mock import AsyncMock, Mock, MagicMock
+import sys
+
+# Third-Party
+import pytest
+
+# First-Party
+from mcpgateway.plugins.framework import (
+ ToolPostInvokeResult,
+ ToolPostInvokePayload,
+ PluginViolation,
+)
+
+
+@pytest.fixture
+def mock_envoy_modules():
+ """Mock envoy protobuf modules to avoid proto dependencies."""
+ # Create mock modules
+ mock_ep = MagicMock()
+ mock_ep_grpc = MagicMock()
+ mock_core = MagicMock()
+ mock_http_status = MagicMock()
+
+ # Add to sys.modules before importing server
+ sys.modules["envoy"] = MagicMock()
+ sys.modules["envoy.service"] = MagicMock()
+ sys.modules["envoy.service.ext_proc"] = MagicMock()
+ sys.modules["envoy.service.ext_proc.v3"] = MagicMock()
+ sys.modules["envoy.service.ext_proc.v3.external_processor_pb2"] = mock_ep
+ sys.modules["envoy.service.ext_proc.v3.external_processor_pb2_grpc"] = (
+ mock_ep_grpc
+ )
+ sys.modules["envoy.config"] = MagicMock()
+ sys.modules["envoy.config.core"] = MagicMock()
+ sys.modules["envoy.config.core.v3"] = MagicMock()
+ sys.modules["envoy.config.core.v3.base_pb2"] = mock_core
+ sys.modules["envoy.type"] = MagicMock()
+ sys.modules["envoy.type.v3"] = MagicMock()
+ sys.modules["envoy.type.v3.http_status_pb2"] = mock_http_status
+
+ yield {
+ "ep": mock_ep,
+ "ep_grpc": mock_ep_grpc,
+ "core": mock_core,
+ "http_status": mock_http_status,
+ }
+
+ # Cleanup
+ for key in list(sys.modules.keys()):
+ if key.startswith("envoy"):
+ del sys.modules[key]
+ if "src.server" in sys.modules:
+ del sys.modules["src.server"]
+
+
+@pytest.fixture
+def mock_manager():
+ """Create a mock PluginManager."""
+ mock = Mock()
+ mock.invoke_hook = AsyncMock()
+ return mock
+
+
+@pytest.fixture
+def sample_tool_result_body():
+ """Create a sample tool result body."""
+ return {
+ "jsonrpc": "2.0",
+ "id": "test-123",
+ "result": {
+ "content": [{"type": "text", "text": "Tool execution result"}]
+ },
+ }
+
+
+@pytest.mark.asyncio
+async def test_getToolPostInvokeResponse_continue_processing(
+ mock_envoy_modules, mock_manager, sample_tool_result_body
+):
+ """Test getToolPostInvokeResponse when plugin allows processing to continue."""
+ # Setup mock response objects
+ mock_response = MagicMock()
+ mock_response.HasField.return_value = True
+ mock_response.response_body.response.HasField.return_value = False
+ mock_envoy_modules["ep"].ProcessingResponse.return_value = mock_response
+
+ # Import server after mocking
+ import src.server
+
+ # Setup mock to return continue_processing=True
+ mock_result = ToolPostInvokeResult(
+ continue_processing=True,
+ modified_payload=None,
+ )
+ mock_manager.invoke_hook.return_value = (mock_result, None)
+
+ # Inject mock manager
+ src.server.manager = mock_manager
+
+ # Call the function
+ _ = await src.server.getToolPostInvokeResponse(sample_tool_result_body)
+
+ # Verify the hook was called
+ assert mock_manager.invoke_hook.called
+ call_args = mock_manager.invoke_hook.call_args[0]
+ payload = call_args[1]
+ assert isinstance(payload, ToolPostInvokePayload)
+ assert payload.result == sample_tool_result_body["result"]
+ # assert payload.name == "replaceme" # Replace this after better naming
+
+
+@pytest.mark.asyncio
+async def test_getToolPostInvokeResponse_blocked(
+ mock_envoy_modules, mock_manager, sample_tool_result_body
+):
+ """Test getToolPostInvokeResponse when plugin blocks the response."""
+ # Import server after mocking
+ import src.server
+
+ # Setup mock to return continue_processing=False with violation
+ violation = PluginViolation(
+ reason="Sensitive content detected",
+ description="Tool response contains forbidden content",
+ code="CONTENT_VIOLATION",
+ )
+ mock_result = ToolPostInvokeResult(
+ continue_processing=False,
+ violation=violation,
+ )
+ mock_manager.invoke_hook.return_value = (mock_result, None)
+
+ # Inject mock manager
+ src.server.manager = mock_manager
+
+ # Call the function
+ response = await src.server.getToolPostInvokeResponse(
+ sample_tool_result_body
+ )
+
+ # Verify the hook was called with correct payload
+ assert mock_manager.invoke_hook.called
+ call_args = mock_manager.invoke_hook.call_args[0]
+ payload = call_args[1]
+ assert isinstance(payload, ToolPostInvokePayload)
+ assert payload.result == sample_tool_result_body["result"]
+
+ # Verify response was created (error path taken)
+ assert response is not None
+
+
+@pytest.mark.asyncio
+async def test_getToolPostInvokeResponse_modified_payload(
+ mock_envoy_modules, mock_manager, sample_tool_result_body
+):
+ """Test getToolPostInvokeResponse when plugin modifies the payload."""
+ # Import server after mocking
+ import src.server
+
+ # Setup mock to return modified payload
+ modified_result = {
+ "content": [{"type": "text", "text": "Modified tool result"}]
+ }
+ modified_payload = ToolPostInvokePayload(
+ name="test_tool", result=modified_result
+ )
+ mock_result = ToolPostInvokeResult(
+ continue_processing=True,
+ modified_payload=modified_payload,
+ )
+ mock_manager.invoke_hook.return_value = (mock_result, None)
+
+ # Inject mock manager
+ src.server.manager = mock_manager
+
+ # Call the function
+ response = await src.server.getToolPostInvokeResponse(
+ sample_tool_result_body
+ )
+
+ # Verify the hook was called
+ assert mock_manager.invoke_hook.called
+
+ # Verify response was created
+ assert response is not None
+
+
+@pytest.mark.asyncio
+async def test_getToolPostInvokeResponse_multiple_content_items(
+ mock_envoy_modules, mock_manager
+):
+ """Test getToolPostInvokeResponse with multiple content items."""
+ # Setup mock response
+ mock_response = MagicMock()
+ mock_envoy_modules["ep"].ProcessingResponse.return_value = mock_response
+
+ # Import server after mocking
+ import src.server
+
+ body = {
+ "jsonrpc": "2.0",
+ "id": "test-789",
+ "result": {
+ "content": [
+ {"type": "text", "text": "First item"},
+ {"type": "text", "text": "Second item"},
+ {"type": "image", "url": "http://example.com/img.png"},
+ ]
+ },
+ }
+
+ mock_result = ToolPostInvokeResult(continue_processing=True)
+ mock_manager.invoke_hook.return_value = (mock_result, None)
+
+ # Inject mock manager
+ src.server.manager = mock_manager
+
+ # Call the function
+ _ = await src.server.getToolPostInvokeResponse(body)
+
+ # Verify the payload passed to the hook contains all content
+ call_args = mock_manager.invoke_hook.call_args[0]
+ payload = call_args[1]
+ assert len(payload.result["content"]) == 3
+ assert payload.result["content"][0]["text"] == "First item"
+ assert payload.result["content"][1]["text"] == "Second item"
+ assert payload.result["content"][2]["url"] == "http://example.com/img.png"
From eafe4c2c4304bb18ca2fc2e7632452020862cfdd Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Thu, 12 Feb 2026 14:37:30 -0700
Subject: [PATCH 10/27] :recycle::white_check_mark: Tool response body
buffering
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
src/server.py | 172 +++++++++++++++++++-------------------
tests/test_server.py | 191 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 274 insertions(+), 89 deletions(-)
diff --git a/src/server.py b/src/server.py
index 4dac2aa..c5f209a 100644
--- a/src/server.py
+++ b/src/server.py
@@ -229,6 +229,85 @@ async def getPromptPreFetchResponse(body):
return body_resp
+# ============================================================================
+# RESPONSE BODY PROCESSING HELPER
+# ============================================================================
+
+
+async def process_response_body_buffer(buffer: bytearray):
+ """Process buffered response body content.
+
+ Parses the buffered content (supporting both SSE and plain JSON-RPC formats),
+ and invokes the tool post-invoke hook if it's a tool result.
+
+ Args:
+ buffer: The accumulated response body bytes
+
+ Returns:
+ ProcessingResponse to send back to Envoy
+ """
+ if not buffer:
+ # Empty buffer at end of stream
+ logger.debug("End of stream with empty buffer")
+ return ep.ProcessingResponse(
+ response_body=ep.BodyResponse(response=ep.CommonResponse())
+ )
+
+ try:
+ text = buffer.decode("utf-8")
+ except UnicodeDecodeError:
+ logger.debug("Response body not UTF-8; skipping")
+ return ep.ProcessingResponse(
+ response_body=ep.BodyResponse(response=ep.CommonResponse())
+ )
+
+ lines = text.split("\n")
+ logger.debug(f"Response body text: {lines}")
+
+ # Handle both SSE format and plain JSON-RPC format
+ data = None
+
+ # Check if this is SSE format (starts with "event:" or "data:")
+ if text.strip().startswith(("event:", "data:")):
+ # Parse SSE format
+ lines = text.split("\n")
+ for line in lines:
+ line = line.strip()
+ if line.startswith("data:"):
+ json_str = line[5:].strip() # Remove "data:" prefix
+ logger.debug(f"Extracted JSON from SSE: {json_str}")
+ try:
+ data = json.loads(json_str)
+ break
+ except json.JSONDecodeError:
+ continue
+ else:
+ # Parse plain JSON-RPC format
+ lines = [line.strip() for line in text.split("\n") if line.strip()]
+ if lines:
+ try:
+ data = json.loads(lines[0])
+ except json.JSONDecodeError as e:
+ logger.error(f"Failed to parse JSON: {e}")
+
+ if data:
+ logger.debug(f"Parsed response data: {data}")
+
+ # Check if this is a tool result response
+ if "result" in data and "content" in data["result"]:
+ logger.info("Invoking tool post-invoke hook")
+ return await getToolPostInvokeResponse(data)
+ else:
+ return ep.ProcessingResponse(
+ response_body=ep.BodyResponse(response=ep.CommonResponse())
+ )
+ else:
+ logger.warning("No data parsed from response body")
+ return ep.ProcessingResponse(
+ response_body=ep.BodyResponse(response=ep.CommonResponse())
+ )
+
+
# ============================================================================
# ENVOY EXTERNAL PROCESSOR SERVICER
# ============================================================================
@@ -356,95 +435,10 @@ async def Process(
"End of stream reached, processing complete buffered response"
)
- # Process buffered content if any
- if resp_body_buf:
- try:
- text = resp_body_buf.decode("utf-8")
- except UnicodeDecodeError:
- logger.debug("Response body not UTF-8; skipping")
- body_resp = ep.ProcessingResponse(
- response_body=ep.BodyResponse(
- response=ep.CommonResponse()
- )
- )
- else:
- lines = text.split("\n")
- logger.debug(f"Response body text: {lines}")
-
- # Handle both SSE format and plain JSON-RPC format
- data = None
-
- # Check if this is SSE format (starts with "event:" or "data:")
- if text.strip().startswith(("event:", "data:")):
- # Parse SSE format
- lines = text.split("\n")
- for line in lines:
- line = line.strip()
- if line.startswith("data:"):
- json_str = line[
- 5:
- ].strip() # Remove "data:" prefix
- logger.debug(
- f"Extracted JSON from SSE: {json_str}"
- )
- try:
- data = json.loads(json_str)
- break
- except json.JSONDecodeError:
- continue
- else:
- # Parse plain JSON-RPC format
- lines = [
- line.strip()
- for line in text.split("\n")
- if line.strip()
- ]
- if lines:
- try:
- data = json.loads(lines[0])
- except json.JSONDecodeError as e:
- logger.error(
- f"Failed to parse JSON: {e}"
- )
-
- if data:
- logger.debug(f"Parsed response data: {data}")
-
- # Check if this is a tool result response
- if (
- "result" in data
- and "content" in data["result"]
- ):
- logger.info(
- "Invoking tool post-invoke hook"
- )
- body_resp = await getToolPostInvokeResponse(
- data
- )
- else:
- body_resp = ep.ProcessingResponse(
- response_body=ep.BodyResponse(
- response=ep.CommonResponse()
- )
- )
- else:
- logger.warning(
- "No data parsed from response body"
- )
- body_resp = ep.ProcessingResponse(
- response_body=ep.BodyResponse(
- response=ep.CommonResponse()
- )
- )
- else:
- # Empty buffer at end of stream
- logger.debug("End of stream with empty buffer")
- body_resp = ep.ProcessingResponse(
- response_body=ep.BodyResponse(
- response=ep.CommonResponse()
- )
- )
-
+ # Process the buffered content
+ body_resp = await process_response_body_buffer(
+ resp_body_buf
+ )
yield body_resp
resp_body_buf.clear()
else:
diff --git a/tests/test_server.py b/tests/test_server.py
index 0a5f033..efc0fe7 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -6,6 +6,7 @@
# Standard
from unittest.mock import AsyncMock, Mock, MagicMock
import sys
+import json
# Third-Party
import pytest
@@ -79,6 +80,33 @@ def sample_tool_result_body():
}
+def setup_response_mocks(mock_envoy_modules):
+ """Setup common response mocks."""
+ mock_envoy_modules["ep"].ProcessingResponse.return_value = MagicMock()
+ mock_envoy_modules["ep"].BodyResponse.return_value = MagicMock()
+ mock_envoy_modules["ep"].CommonResponse.return_value = MagicMock()
+
+
+def setup_manager_with_result(mock_manager, continue_processing=True):
+ """Setup mock manager with a tool post-invoke result."""
+ mock_result = ToolPostInvokeResult(continue_processing=continue_processing)
+ mock_manager.invoke_hook.return_value = (mock_result, None)
+ return mock_manager
+
+
+def verify_payload_content(payload, expected_result, expected_text):
+ """Verify payload contains expected content."""
+ assert isinstance(payload, ToolPostInvokePayload)
+ assert payload.result == expected_result
+ assert payload.result["content"][0]["type"] == "text"
+ assert payload.result["content"][0]["text"] == expected_text
+
+
+# ============================================================================
+# Tool Post-Invoke Hook Tests
+# ============================================================================
+
+
@pytest.mark.asyncio
async def test_getToolPostInvokeResponse_continue_processing(
mock_envoy_modules, mock_manager, sample_tool_result_body
@@ -230,3 +258,166 @@ async def test_getToolPostInvokeResponse_multiple_content_items(
assert payload.result["content"][0]["text"] == "First item"
assert payload.result["content"][1]["text"] == "Second item"
assert payload.result["content"][2]["url"] == "http://example.com/img.png"
+
+
+# ============================================================================
+# Response Body Processing Tests
+# ============================================================================
+
+
+@pytest.mark.asyncio
+async def test_process_response_body_buffer_with_tool_result(
+ mock_envoy_modules, mock_manager
+):
+ """Test process_response_body_buffer with a tool result."""
+ setup_response_mocks(mock_envoy_modules)
+ import src.server
+
+ setup_manager_with_result(mock_manager)
+ src.server.manager = mock_manager
+
+ tool_result = {
+ "jsonrpc": "2.0",
+ "id": "test-123",
+ "result": {"content": [{"type": "text", "text": "Result"}]},
+ }
+ buffer = bytearray(json.dumps(tool_result).encode("utf-8"))
+ response = await src.server.process_response_body_buffer(buffer)
+
+ assert mock_manager.invoke_hook.called
+ payload = mock_manager.invoke_hook.call_args[0][1]
+ verify_payload_content(payload, tool_result["result"], "Result")
+ # Verify ProcessingResponse was returned
+ assert response is not None
+
+
+@pytest.mark.asyncio
+async def test_process_response_body_buffer_with_sse_format(
+ mock_envoy_modules, mock_manager
+):
+ """Test process_response_body_buffer with SSE formatted content."""
+ setup_response_mocks(mock_envoy_modules)
+ import src.server
+
+ setup_manager_with_result(mock_manager)
+ src.server.manager = mock_manager
+
+ tool_result = {
+ "jsonrpc": "2.0",
+ "id": "test-sse",
+ "result": {"content": [{"type": "text", "text": "SSE data"}]},
+ }
+ sse_body = f"event: message\ndata: {json.dumps(tool_result)}\n\n"
+ buffer = bytearray(sse_body.encode("utf-8"))
+ response = await src.server.process_response_body_buffer(buffer)
+
+ assert mock_manager.invoke_hook.called
+ payload = mock_manager.invoke_hook.call_args[0][1]
+ verify_payload_content(payload, tool_result["result"], "SSE data")
+ # Verify ProcessingResponse was returned
+ assert response is not None
+
+
+@pytest.mark.asyncio
+async def test_process_response_body_buffer_empty(
+ mock_envoy_modules, mock_manager
+):
+ """Test process_response_body_buffer with empty buffer."""
+ setup_response_mocks(mock_envoy_modules)
+ import src.server
+
+ src.server.manager = mock_manager
+ response = await src.server.process_response_body_buffer(bytearray())
+
+ # Verify ProcessingResponse was returned
+ assert response is not None
+ assert not mock_manager.invoke_hook.called, (
+ "Tool post-invoke hook should not be called for empty buffer"
+ )
+
+
+@pytest.mark.asyncio
+async def test_process_response_body_buffer_non_tool_result(
+ mock_envoy_modules, mock_manager
+):
+ """Test process_response_body_buffer with non-tool result (error response)."""
+ setup_response_mocks(mock_envoy_modules)
+ import src.server
+
+ src.server.manager = mock_manager
+
+ error_response = {
+ "jsonrpc": "2.0",
+ "id": "test-error",
+ "error": {"code": -32000, "message": "Error"},
+ }
+ buffer = bytearray(json.dumps(error_response).encode("utf-8"))
+ response = await src.server.process_response_body_buffer(buffer)
+
+ # Verify ProcessingResponse was returned
+ assert response is not None
+ assert not mock_manager.invoke_hook.called, (
+ "Tool post-invoke hook should not be called for error responses"
+ )
+
+
+@pytest.mark.asyncio
+async def test_process_response_body_buffer_multiple_chunks_scenario(
+ mock_envoy_modules, mock_manager
+):
+ """Test buffering: content in chunks, then empty end_of_stream chunk.
+
+ Simulates: chunk1 (content) + chunk2 (content) + chunk3 (empty, end_of_stream).
+ """
+ setup_response_mocks(mock_envoy_modules)
+ import src.server
+
+ setup_manager_with_result(mock_manager)
+ src.server.manager = mock_manager
+
+ tool_result = {
+ "jsonrpc": "2.0",
+ "id": "test-multi-chunk",
+ "result": {"content": [{"type": "text", "text": "Multi chunk data"}]},
+ }
+ body_bytes = json.dumps(tool_result).encode("utf-8")
+
+ # Simulate buffering: chunk1 + chunk2 + empty chunk
+ buffer = bytearray()
+ buffer.extend(body_bytes[:25]) # Chunk 1
+ buffer.extend(body_bytes[25:]) # Chunk 2
+ buffer.extend(b"") # Chunk 3 (empty, triggers processing)
+
+ response = await src.server.process_response_body_buffer(buffer)
+
+ assert mock_manager.invoke_hook.called
+ payload = mock_manager.invoke_hook.call_args[0][1]
+ verify_payload_content(payload, tool_result["result"], "Multi chunk data")
+ # Verify ProcessingResponse was returned
+ assert response is not None
+
+
+@pytest.mark.asyncio
+async def test_process_response_body_buffer_single_chunk_with_end_of_stream(
+ mock_envoy_modules, mock_manager
+):
+ """Test buffering: all content in one chunk with end_of_stream."""
+ setup_response_mocks(mock_envoy_modules)
+ import src.server
+
+ setup_manager_with_result(mock_manager)
+ src.server.manager = mock_manager
+
+ tool_result = {
+ "jsonrpc": "2.0",
+ "id": "test-single",
+ "result": {"content": [{"type": "text", "text": "Single chunk"}]},
+ }
+ buffer = bytearray(json.dumps(tool_result).encode("utf-8"))
+ response = await src.server.process_response_body_buffer(buffer)
+
+ assert mock_manager.invoke_hook.called
+ payload = mock_manager.invoke_hook.call_args[0][1]
+ verify_payload_content(payload, tool_result["result"], "Single chunk")
+ # Verify ProcessingResponse was returned
+ assert response is not None
From 6246685fb9aa1cc84e38600944e166d3605942ed Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Fri, 13 Feb 2026 12:32:26 -0700
Subject: [PATCH 11/27] :recycle: First pass nemocheck as internal and
nemocheck_external as external plugin
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
plugins/examples/nemocheck/README.md | 137 +++++------------
.../nemocheck/{nemocheck => }/__init__.py | 0
.../config.yaml | 4 +-
.../nemocheck/nemocheck/plugin-manifest.yaml | 9 --
.../examples/nemocheck/nemocheck/plugin.py | 145 ------------------
.../plugin-manifest.yaml | 2 +-
.../plugin.py | 108 +++++++------
plugins/examples/nemocheck/tests/__init__.py | 1 +
.../nemocheck/tests/test_nemocheck.py | 8 +-
.../.dockerignore | 0
.../.env.template | 0
.../.ruff.toml | 0
.../Containerfile | 0
.../MANIFEST.in | 0
.../Makefile | 0
plugins/examples/nemocheck_external/README.md | 123 +++++++++++++++
.../k8deploy/Makefile | 0
.../k8deploy/config-tools.yaml | 0
.../k8deploy/deploy.yaml | 0
.../k8deploy/server.yaml | 0
.../pyproject.toml | 0
.../resources/plugins/config.yaml | 4 +-
.../run-server.sh | 0
.../{nemocheck => nemocheck_external}/uv.lock | 0
plugins/examples/nemocheckinternal/README.md | 42 -----
.../examples/nemocheckinternal/__init__.py | 7 -
26 files changed, 229 insertions(+), 361 deletions(-)
rename plugins/examples/nemocheck/{nemocheck => }/__init__.py (100%)
rename plugins/examples/{nemocheckinternal => nemocheck}/config.yaml (95%)
delete mode 100644 plugins/examples/nemocheck/nemocheck/plugin-manifest.yaml
delete mode 100644 plugins/examples/nemocheck/nemocheck/plugin.py
rename plugins/examples/{nemocheckinternal => nemocheck}/plugin-manifest.yaml (91%)
rename plugins/examples/{nemocheckinternal => nemocheck}/plugin.py (59%)
rename plugins/examples/{nemocheck => nemocheck_external}/.dockerignore (100%)
rename plugins/examples/{nemocheck => nemocheck_external}/.env.template (100%)
rename plugins/examples/{nemocheck => nemocheck_external}/.ruff.toml (100%)
rename plugins/examples/{nemocheck => nemocheck_external}/Containerfile (100%)
rename plugins/examples/{nemocheck => nemocheck_external}/MANIFEST.in (100%)
rename plugins/examples/{nemocheck => nemocheck_external}/Makefile (100%)
create mode 100644 plugins/examples/nemocheck_external/README.md
rename plugins/examples/{nemocheck => nemocheck_external}/k8deploy/Makefile (100%)
rename plugins/examples/{nemocheck => nemocheck_external}/k8deploy/config-tools.yaml (100%)
rename plugins/examples/{nemocheck => nemocheck_external}/k8deploy/deploy.yaml (100%)
rename plugins/examples/{nemocheck => nemocheck_external}/k8deploy/server.yaml (100%)
rename plugins/examples/{nemocheck => nemocheck_external}/pyproject.toml (100%)
rename plugins/examples/{nemocheck => nemocheck_external}/resources/plugins/config.yaml (93%)
rename plugins/examples/{nemocheck => nemocheck_external}/run-server.sh (100%)
rename plugins/examples/{nemocheck => nemocheck_external}/uv.lock (100%)
delete mode 100644 plugins/examples/nemocheckinternal/README.md
delete mode 100644 plugins/examples/nemocheckinternal/__init__.py
diff --git a/plugins/examples/nemocheck/README.md b/plugins/examples/nemocheck/README.md
index 48d3570..eb064ef 100644
--- a/plugins/examples/nemocheck/README.md
+++ b/plugins/examples/nemocheck/README.md
@@ -1,94 +1,44 @@
-# NemoCheck for Plugin Adapter
+# NemoCheck Internal Plugin
-Adapter for Nemo-Check guardrails.
+This directory contains the core `NemoCheck` plugin implementation used by both internal and external plugins.
-
-## Run plugin in kind cluster
-
- 1. Run Nemo Guardrails check server. Instructions [here](#Deploy-checkserver)
- 1. Update `CHECK_ENDPOINT` variable in k8deploy/deploy.yaml to point to guardrails check server endpoint
-
- ```bash
- cd plugins-adapter/plugins/examples/nemocheck
- make deploy
- ```
- 1.
-
- Non-kind k8 cluster instructions
-
- ```bash
- cd plugins-adapter/plugins/examples/nemocheck
- make container-build
- # push image to your container repo and update image name in k8deploy/deploy.yaml
- kubectl apply -f k8deploy/deploy.yaml
-
- ```
-
-
- 1. Update plugin adapter to call this as an external plugin
-
- ```bash
- cd ../../.. #project root directory plugins-adapter`
- cp resources/config/external_plugin_nemocheck.yaml resources/config/config.yaml
- make all
- ```
-
-## Test with MCP inspector
- * Add allowed tools to `plugins-adapter/plugins/examples/nemocheck/k8deploy/config-tools.yaml#check_tool_call_safety`
-
-
-| config-tools.yaml line-127 |
-Updated to add test2_hello_world |
-
-
-
-
-
-```python
-@action(is_system_action=True)
-async def check_tool_call_safety(tool_calls=None, context=None):
- """Allow list for tool execution."""
- ...
- allowed_tools = ["get_weather", "search_web",
- "get_time", "slack_read_messages"]
- ...
-```
-
- |
-
-
-```python
-@action(is_system_action=True)
-async def check_tool_call_safety(tool_calls=None, context=None):
- """Allow list for tool execution."""
- ...
- allowed_tools = ["get_weather", "search_web", "get_time",
- "test2_hello_world", "slack_read_messages"]
- ...
-```
-
- |
-
-
-
-
- * Redeploy check server
- * Open mcp inspector. Try tools in allow list vs tools not in allow list
-
-
-## Deploy-checkserver
+## Prerequisites: Nemo-check server
* Refer to [orignal repo](https://github.com/m-misiura/demos/tree/main/nemo_openshift/guardrail-checks/deployment) for full instructions
* Instructions adpated for mcpgateway kind cluster to work with an llm proxy routing to some open ai compatable backend below
- * Makefile has targets to load checkserver to kind cluster, etc.
```bash
- cd plugins-adapter/plugins/examples/nemocheck/k8deploy
- make deploy
+ docker pull quay.io/rh-ee-mmisiura/nemo-guardrails:guardrails_checks_with_tools_o1_v1
+ kind load docker-image quay.io/rh-ee-mmisiura/nemo-guardrails:guardrails_checks_with_tools_o1_v1 --name mcp-gateway
+ cd plugins-adapter/plugins/examples/nemocheck/k8deploy
+ kubectl apply -f config-tools.yaml
+ kubectl apply -f server.yaml
```
+## Installation
+
+1. Find url of nemo-check-server service. E.g., from svc in `server.yaml`
+1. Update `${project_root}/resources/config/config.yaml`. Add the blob below, merge if other `plugin`s or `plugin_dir`s already exists. Sample file [here](/resources/config/nemocheck-internal-config.yaml)
+
+ ```yaml
+ # plugins/config.yaml - Main plugin configuration file
+ plugins:
+ - name: "NemoCheck"
+ kind: "plugins.examples.nemocheck.nemocheck.plugin.NemoCheck"
+ description: "Adapter for nemo check server"
+ version: "0.1.0"
+ hooks: ["tool_pre_invoke", "tool_post_invoke"]
+ mode: "enforce" # enforce | permissive | disabled
+ config:
+ checkserver_url: "http://nemo-guardrails-service:8000/v1/guardrail/checks"
+ # Plugin directories to scan
+ plugin_dirs:
+ - "plugins/examples/nemocheck" # Nemo Check Server plugins
+ ```
+1. In `config.yaml` ensure key `plugins.config.checkserver_url` points to the correct service
+1. Start plugin adapter
-## Plugin Development
+## Plugin Development
To install dependencies with dev packages (required for linting and testing):
@@ -121,30 +71,13 @@ make test
## Code Linting
-Before checking in any code for the project, please lint the code. This can be done using:
+Before checking in any code for the project, please lint the code. This can be done using:
```bash
make lint-fix
```
-## Runtime (server)
+# Test
-This project uses [chuck-mcp-runtime](https://github.com/chrishayuk/chuk-mcp-runtime) to run external plugins as a standardized MCP server.
-
-To build the container image:
-
-```bash
-make build
-```
-
-To run the container:
-
-```bash
-make start
-```
-
-To stop the container:
-
-```bash
-make stop
-```
+1. Open mcp-inspector to the mcp-gateway
+1. Try running a tool configured/not configured in nemo check config allow list in configmap [E.g.](/plugins/examples/nemocheck/k8deploy/config-tools.yaml)
diff --git a/plugins/examples/nemocheck/nemocheck/__init__.py b/plugins/examples/nemocheck/__init__.py
similarity index 100%
rename from plugins/examples/nemocheck/nemocheck/__init__.py
rename to plugins/examples/nemocheck/__init__.py
diff --git a/plugins/examples/nemocheckinternal/config.yaml b/plugins/examples/nemocheck/config.yaml
similarity index 95%
rename from plugins/examples/nemocheckinternal/config.yaml
rename to plugins/examples/nemocheck/config.yaml
index d930000..d9f97b1 100644
--- a/plugins/examples/nemocheckinternal/config.yaml
+++ b/plugins/examples/nemocheck/config.yaml
@@ -1,5 +1,5 @@
plugins:
- - name: "NemoCheckv2"
+ - name: "NemoCheck"
kind: "plugins.examples.nemocheckinternal.plugin.NemoCheckv2"
description: "Nemo Check Adapter"
version: "0.1.0"
@@ -17,7 +17,7 @@ plugins:
# Plugin directories to scan
plugin_dirs:
- - "nemocheckv2"
+ - "nemocheck"
# Global plugin settings
plugin_settings:
diff --git a/plugins/examples/nemocheck/nemocheck/plugin-manifest.yaml b/plugins/examples/nemocheck/nemocheck/plugin-manifest.yaml
deleted file mode 100644
index 3e8fe2e..0000000
--- a/plugins/examples/nemocheck/nemocheck/plugin-manifest.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-description: "Adapter for Nemo-Check guardrails"
-author: "julianstephen"
-version: "0.1.0"
-available_hooks:
- - "prompt_pre_hook"
- - "prompt_post_hook"
- - "tool_pre_hook"
- - "tool_post_hook"
-default_configs:
diff --git a/plugins/examples/nemocheck/nemocheck/plugin.py b/plugins/examples/nemocheck/nemocheck/plugin.py
deleted file mode 100644
index c5edce8..0000000
--- a/plugins/examples/nemocheck/nemocheck/plugin.py
+++ /dev/null
@@ -1,145 +0,0 @@
-"""Adapter for Nemo-Check guardrails.
-
-Copyright 2025
-SPDX-License-Identifier: Apache-2.0
-Authors: julianstephen
-
-This module loads configurations for plugins.
-"""
-
-# First-Party
-from mcpgateway.plugins.framework import (
- Plugin,
- PluginConfig,
- PluginContext,
- PromptPosthookPayload,
- PromptPosthookResult,
- PromptPrehookPayload,
- PromptPrehookResult,
- ToolPostInvokePayload,
- ToolPostInvokeResult,
- ToolPreInvokePayload,
- ToolPreInvokeResult,
- PluginViolation,
-)
-
-
-import logging
-import os
-import requests
-
-headers = {
- "Content-Type": "application/json",
-}
-# Initialize logging service first
-logger = logging.getLogger(__name__)
-log_level = os.getenv("LOGLEVEL", "INFO").upper()
-logger.setLevel(log_level)
-
-MODEL_NAME = os.getenv("NEMO_MODEL", "meta-llama/llama-3-3-70b-instruct") # Currently only for logging.
-CHECK_ENDPOINT = os.getenv("CHECK_ENDPOINT", "http://nemo-guardrails-service:8000")
-
-
-class NemoCheck(Plugin):
- """Adapter for Nemo-Check guardrails."""
-
- def __init__(self, config: PluginConfig):
- """Entry init block for plugin.
-
- Args:
- logger: logger that the skill can make use of
- config: the skill configuration
- """
- super().__init__(config)
-
- async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult:
- """The plugin hook run before a prompt is retrieved and rendered.
-
- Args:
- payload: The prompt payload to be analyzed.
- context: contextual information about the hook call.
-
- Returns:
- The result of the plugin's analysis, including whether the prompt can proceed.
- """
-
- return PromptPrehookResult(continue_processing=True)
-
- async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult:
- """Plugin hook run after a prompt is rendered.
-
- Args:
- payload: The prompt payload to be analyzed.
- context: Contextual information about the hook call.
-
- Returns:
- The result of the plugin's analysis, including whether the prompt can proceed.
- """
- return PromptPosthookResult(continue_processing=True)
-
- async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult:
- """Plugin hook run before a tool is invoked.
-
- Args:
- payload: The tool payload to be analyzed.
- context: Contextual information about the hook call.
-
- Returns:
- The result of the plugin's analysis, including whether the tool can proceed.
- """
- logger.info("tool_pre_invoke....")
- logger.info(payload)
- tool_name = payload.name # ("tool_name", None)
- check_nemo_payload = {
- "model": MODEL_NAME,
- "messages": [
- {
- "role": "assistant",
- "tool_calls": [
- {
- "id": "call_plug_adap_nem_check_123",
- "type": "function",
- "function": {
- "name": tool_name,
- "arguments": payload.args.get("tool_args", None),
- },
- }
- ],
- }
- ],
- }
- violation = None
- response = requests.post(CHECK_ENDPOINT, headers=headers, json=check_nemo_payload)
- if response.status_code == 200:
- data = response.json()
- status = data.get("status", "blocked")
- logger.debug(f"rails reply:{data}")
- if status == "success":
- metadata = data.get("rails_status")
- result = ToolPreInvokeResult(continue_processing=True, metadata=metadata)
- else:
- metadata = data.get("rails_status")
- violation = PluginViolation(
- reason=f"Tool Check status:{status}", description="Rails check blocked request", code=f"checkserver_http_status_code:{response.status_code}", details=metadata
- )
- result = ToolPreInvokeResult(continue_processing=False, violation=violation, metadata=metadata)
-
- else:
- violation = PluginViolation(
- reason="Tool Check Unavailable", description="Tool arguments check server returned error:", code=f"checkserver_http_status_code:{response.status_code}", details={}
- )
- result = ToolPreInvokeResult(continue_processing=False, violation=violation)
-
- return result
-
- async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult:
- """Plugin hook run after a tool is invoked.
-
- Args:
- payload: The tool result payload to be analyzed.
- context: Contextual information about the hook call.
-
- Returns:
- The result of the plugin's analysis, including whether the tool result should proceed.
- """
- return ToolPostInvokeResult(continue_processing=True)
diff --git a/plugins/examples/nemocheckinternal/plugin-manifest.yaml b/plugins/examples/nemocheck/plugin-manifest.yaml
similarity index 91%
rename from plugins/examples/nemocheckinternal/plugin-manifest.yaml
rename to plugins/examples/nemocheck/plugin-manifest.yaml
index d8fa01f..779bf7b 100644
--- a/plugins/examples/nemocheckinternal/plugin-manifest.yaml
+++ b/plugins/examples/nemocheck/plugin-manifest.yaml
@@ -1,5 +1,5 @@
description: "Nemo Check Adapter"
-name: NemoCheckv2
+name: NemoCheck
author: "julianstephen"
version: "0.1.0"
available_hooks:
diff --git a/plugins/examples/nemocheckinternal/plugin.py b/plugins/examples/nemocheck/plugin.py
similarity index 59%
rename from plugins/examples/nemocheckinternal/plugin.py
rename to plugins/examples/nemocheck/plugin.py
index 30b3223..598bb4e 100644
--- a/plugins/examples/nemocheckinternal/plugin.py
+++ b/plugins/examples/nemocheck/plugin.py
@@ -1,10 +1,10 @@
-"""Nemo Check Adapter.
+"""Nemo Check Plugin
Copyright 2025
SPDX-License-Identifier: Apache-2.0
Authors: julianstephen
-This module loads configurations for plugins.
+This module provides the core Nemo Check guardrails plugin implementation.
"""
# First-Party
@@ -26,41 +26,37 @@
import logging
import os
import requests
-import json
-# Initialize logging service first
+# Initialize logging
logger = logging.getLogger(__name__)
log_level = os.getenv("LOGLEVEL", "INFO").upper()
logger.setLevel(log_level)
-MODEL_NAME = os.getenv(
- "NEMO_MODEL", "meta-llama/llama-3-3-70b-instruct"
-) # Currently only for logging.
-CHECK_ENDPOINT = os.getenv("CHECK_ENDPOINT", "http://nemo-guardrails-service:8000")
-
+MODEL_NAME = os.getenv("NEMO_MODEL", "meta-llama/llama-3-3-70b-instruct")
+DEFAULT_CHECK_ENDPOINT = os.getenv(
+ "CHECK_ENDPOINT", "http://nemo-guardrails-service:8000"
+)
-headers = {
+HEADERS = {
"Content-Type": "application/json",
}
-class NemoCheckv2(Plugin):
- """Nemo Check Adapter."""
+class NemoCheck(Plugin):
+ """Nemo Check guardrails plugin."""
def __init__(self, config: PluginConfig):
- """Entry init block for plugin.
+ """Initialize the plugin.
Args:
- logger: logger that the skill can make use of
- config: the skill configuration
+ config: The plugin configuration
"""
- global CHECK_ENDPOINT
- logger.info(f"plugin config {config}")
- endpoint = config.config.get("checkserver_url", None)
- if endpoint is not None:
- CHECK_ENDPOINT = endpoint
- logger.info(f"checkserver at {config}:{CHECK_ENDPOINT}")
super().__init__(config)
+ # Allow config to override the endpoint
+ self.check_endpoint = config.config.get(
+ "checkserver_url", DEFAULT_CHECK_ENDPOINT
+ )
+ logger.info(f"Nemo Check endpoint: {self.check_endpoint}")
async def prompt_pre_fetch(
self, payload: PromptPrehookPayload, context: PluginContext
@@ -69,10 +65,10 @@ async def prompt_pre_fetch(
Args:
payload: The prompt payload to be analyzed.
- context: contextual information about the hook call.
+ context: Contextual information about the hook call.
Returns:
- The result of the plugin's analysis, including whether the prompt can proceed.
+ The result of the plugin's analysis.
"""
return PromptPrehookResult(continue_processing=True)
@@ -123,41 +119,55 @@ async def tool_pre_invoke(
}
],
}
- violation = None
- response = requests.post(
- CHECK_ENDPOINT, headers=headers, json=check_nemo_payload
- )
- if response.status_code == 200:
- data = response.json()
- status = data.get("status", "blocked")
- logger.debug(f"rails reply:{data}")
- if status == "success":
- metadata = data.get("rails_status")
- result = ToolPreInvokeResult(
- continue_processing=True, metadata=metadata
- )
+
+ try:
+ response = requests.post(
+ self.check_endpoint, headers=HEADERS, json=check_nemo_payload
+ )
+
+ if response.status_code == 200:
+ data = response.json()
+ status = data.get("status", "blocked")
+ logger.debug(f"rails reply: {data}")
+
+ if status == "success":
+ metadata = data.get("rails_status")
+ return ToolPreInvokeResult(
+ continue_processing=True, metadata=metadata
+ )
+ else:
+ metadata = data.get("rails_status")
+ violation = PluginViolation(
+ reason=f"Tool Check status: {status}",
+ description="Rails check blocked request",
+ code=f"checkserver_http_status_code:{response.status_code}",
+ details=metadata,
+ )
+ return ToolPreInvokeResult(
+ continue_processing=False,
+ violation=violation,
+ metadata=metadata,
+ )
else:
- metadata = data.get("rails_status")
violation = PluginViolation(
- reason=f"Check tool rails:{status}.",
- description=json.dumps(data),
+ reason="Tool Check Unavailable",
+ description="Tool arguments check server returned error",
code=f"checkserver_http_status_code:{response.status_code}",
- details=metadata,
+ details={},
)
- result = ToolPreInvokeResult(
- continue_processing=False, violation=violation, metadata=metadata
+ return ToolPreInvokeResult(
+ continue_processing=False, violation=violation
)
- else:
+ except Exception as e:
+ logger.error(f"Error calling Nemo Check endpoint: {e}")
violation = PluginViolation(
- reason="Tool Check Unavailable",
- description="Tool arguments check server returned error:",
- code=f"checkserver_http_status_code:{response.status_code}",
+ reason="Tool Check Error",
+ description=f"Failed to connect to check server: {str(e)}",
+ code="checkserver_connection_error",
details={},
)
- result = ToolPreInvokeResult(continue_processing=False, violation=violation)
-
- return result
+ return ToolPreInvokeResult(continue_processing=False, violation=violation)
async def tool_post_invoke(
self, payload: ToolPostInvokePayload, context: PluginContext
diff --git a/plugins/examples/nemocheck/tests/__init__.py b/plugins/examples/nemocheck/tests/__init__.py
index e69de29..53386c4 100644
--- a/plugins/examples/nemocheck/tests/__init__.py
+++ b/plugins/examples/nemocheck/tests/__init__.py
@@ -0,0 +1 @@
+"""Tests for nemocheckinternal plugin."""
diff --git a/plugins/examples/nemocheck/tests/test_nemocheck.py b/plugins/examples/nemocheck/tests/test_nemocheck.py
index 4321c51..8cd5561 100644
--- a/plugins/examples/nemocheck/tests/test_nemocheck.py
+++ b/plugins/examples/nemocheck/tests/test_nemocheck.py
@@ -4,13 +4,15 @@
import pytest
# First-Party
-from nemocheck.plugin import NemoCheck
from mcpgateway.plugins.framework import (
PluginConfig,
GlobalContext,
PromptPrehookPayload,
)
+# Local
+from plugin import NemoCheck
+
@pytest.mark.asyncio
async def test_nemocheck():
@@ -25,7 +27,9 @@ async def test_nemocheck():
plugin = NemoCheck(config)
# Test your plugin logic
- payload = PromptPrehookPayload(prompt_id="test_prompt", args={"arg0": "This is an argument"})
+ payload = PromptPrehookPayload(
+ prompt_id="test_prompt", args={"arg0": "This is an argument"}
+ )
context = GlobalContext(request_id="1")
result = await plugin.prompt_pre_fetch(payload, context)
assert result.continue_processing
diff --git a/plugins/examples/nemocheck/.dockerignore b/plugins/examples/nemocheck_external/.dockerignore
similarity index 100%
rename from plugins/examples/nemocheck/.dockerignore
rename to plugins/examples/nemocheck_external/.dockerignore
diff --git a/plugins/examples/nemocheck/.env.template b/plugins/examples/nemocheck_external/.env.template
similarity index 100%
rename from plugins/examples/nemocheck/.env.template
rename to plugins/examples/nemocheck_external/.env.template
diff --git a/plugins/examples/nemocheck/.ruff.toml b/plugins/examples/nemocheck_external/.ruff.toml
similarity index 100%
rename from plugins/examples/nemocheck/.ruff.toml
rename to plugins/examples/nemocheck_external/.ruff.toml
diff --git a/plugins/examples/nemocheck/Containerfile b/plugins/examples/nemocheck_external/Containerfile
similarity index 100%
rename from plugins/examples/nemocheck/Containerfile
rename to plugins/examples/nemocheck_external/Containerfile
diff --git a/plugins/examples/nemocheck/MANIFEST.in b/plugins/examples/nemocheck_external/MANIFEST.in
similarity index 100%
rename from plugins/examples/nemocheck/MANIFEST.in
rename to plugins/examples/nemocheck_external/MANIFEST.in
diff --git a/plugins/examples/nemocheck/Makefile b/plugins/examples/nemocheck_external/Makefile
similarity index 100%
rename from plugins/examples/nemocheck/Makefile
rename to plugins/examples/nemocheck_external/Makefile
diff --git a/plugins/examples/nemocheck_external/README.md b/plugins/examples/nemocheck_external/README.md
new file mode 100644
index 0000000..53032ee
--- /dev/null
+++ b/plugins/examples/nemocheck_external/README.md
@@ -0,0 +1,123 @@
+# NemoCheck External Plugin
+
+This is an external plugin deployment for the NemoCheck guardrails adapter. It references the core `NemoCheck` implementation from the `nemocheck` plugin directory.
+
+## Architecture
+
+- **Core Implementation**: `plugins/examples/nemocheck/plugin.py` contains the actual NemoCheck logic
+- **External Deployment**: This directory (`nemocheck_external`) configures and deploys the plugin as an external MCP server
+- **Shared Logic**: Both internal and external deployments use the same underlying implementation to eliminate code duplication
+
+
+## Run plugin in kind cluster
+
+ 1. Run Nemo Guardrails check server. Instructions [here](#Deploy-checkserver)
+ 1. Update `CHECK_ENDPOINT` variable in k8deploy/deploy.yaml to point to guardrails check server endpoint
+
+ ```bash
+ cd plugins-adapter/plugins/examples/nemocheck
+ make deploy
+ ```
+ 1.
+
+ Non-kind k8 cluster instructions
+
+ ```bash
+ cd plugins-adapter/plugins/examples/nemocheck
+ make container-build
+ # push image to your container repo and update image name in k8deploy/deploy.yaml
+ kubectl apply -f k8deploy/deploy.yaml
+
+ ```
+
+
+ 1. Update plugin adapter to call this as an external plugin
+
+ ```bash
+ cd ../../.. #project root directory plugins-adapter`
+ cp resources/config/external_plugin_nemocheck.yaml resources/config/config.yaml
+ make all
+ ```
+
+## Test with MCP inspector
+ * Add allowed tools to `plugins-adapter/plugins/examples/nemocheck/k8deploy/config-tools.yaml#check_tool_call_safety`
+
+
+| config-tools.yaml line-127 |
+Updated to add test2_hello_world |
+
+
+
+
+
+```python
+@action(is_system_action=True)
+async def check_tool_call_safety(tool_calls=None, context=None):
+ """Allow list for tool execution."""
+ ...
+ allowed_tools = ["get_weather", "search_web",
+ "get_time", "slack_read_messages"]
+ ...
+```
+
+ |
+
+
+```python
+@action(is_system_action=True)
+async def check_tool_call_safety(tool_calls=None, context=None):
+ """Allow list for tool execution."""
+ ...
+ allowed_tools = ["get_weather", "search_web", "get_time",
+ "test2_hello_world", "slack_read_messages"]
+ ...
+```
+
+ |
+
+
+
+
+ * Redeploy check server
+ * Open mcp inspector. Try tools in allow list vs tools not in allow list
+
+
+## Deploy-checkserver
+ * Refer to [orignal repo](https://github.com/m-misiura/demos/tree/main/nemo_openshift/guardrail-checks/deployment) for full instructions
+ * Instructions adpated for mcpgateway kind cluster to work with an llm proxy routing to some open ai compatable backend below
+ * Makefile has targets to load checkserver to kind cluster, etc.
+
+ ```bash
+ cd plugins-adapter/plugins/examples/nemocheck/k8deploy
+ make deploy
+
+ ```
+
+
+## Testing
+
+Tests are located in the `nemocheck` plugin directory since both deployments share the same core implementation.
+
+See the [nemocheck README](../nemocheck/README.md#testing) for testing instructions.
+
+## Runtime (server)
+
+This project uses [chuck-mcp-runtime](https://github.com/chrishayuk/chuk-mcp-runtime) to run external plugins as a standardized MCP server.
+
+To build the container image:
+
+```bash
+make build
+```
+
+To run the container:
+
+```bash
+make start
+```
+
+To stop the container:
+
+```bash
+make stop
+```
diff --git a/plugins/examples/nemocheck/k8deploy/Makefile b/plugins/examples/nemocheck_external/k8deploy/Makefile
similarity index 100%
rename from plugins/examples/nemocheck/k8deploy/Makefile
rename to plugins/examples/nemocheck_external/k8deploy/Makefile
diff --git a/plugins/examples/nemocheck/k8deploy/config-tools.yaml b/plugins/examples/nemocheck_external/k8deploy/config-tools.yaml
similarity index 100%
rename from plugins/examples/nemocheck/k8deploy/config-tools.yaml
rename to plugins/examples/nemocheck_external/k8deploy/config-tools.yaml
diff --git a/plugins/examples/nemocheck/k8deploy/deploy.yaml b/plugins/examples/nemocheck_external/k8deploy/deploy.yaml
similarity index 100%
rename from plugins/examples/nemocheck/k8deploy/deploy.yaml
rename to plugins/examples/nemocheck_external/k8deploy/deploy.yaml
diff --git a/plugins/examples/nemocheck/k8deploy/server.yaml b/plugins/examples/nemocheck_external/k8deploy/server.yaml
similarity index 100%
rename from plugins/examples/nemocheck/k8deploy/server.yaml
rename to plugins/examples/nemocheck_external/k8deploy/server.yaml
diff --git a/plugins/examples/nemocheck/pyproject.toml b/plugins/examples/nemocheck_external/pyproject.toml
similarity index 100%
rename from plugins/examples/nemocheck/pyproject.toml
rename to plugins/examples/nemocheck_external/pyproject.toml
diff --git a/plugins/examples/nemocheck/resources/plugins/config.yaml b/plugins/examples/nemocheck_external/resources/plugins/config.yaml
similarity index 93%
rename from plugins/examples/nemocheck/resources/plugins/config.yaml
rename to plugins/examples/nemocheck_external/resources/plugins/config.yaml
index 81d8b9f..bf3f0db 100644
--- a/plugins/examples/nemocheck/resources/plugins/config.yaml
+++ b/plugins/examples/nemocheck_external/resources/plugins/config.yaml
@@ -1,6 +1,6 @@
plugins:
- name: "NemoCheck"
- kind: "nemocheck.plugin.NemoCheck"
+ kind: "plugin.NemoCheck"
description: "Adapter for Nemo-Check guardrails"
version: "0.1.0"
author: "julianstephen"
@@ -17,7 +17,7 @@ plugins:
# Plugin directories to scan
plugin_dirs:
- - "nemocheck"
+ - "../nemocheck"
# Global plugin settings
plugin_settings:
diff --git a/plugins/examples/nemocheck/run-server.sh b/plugins/examples/nemocheck_external/run-server.sh
similarity index 100%
rename from plugins/examples/nemocheck/run-server.sh
rename to plugins/examples/nemocheck_external/run-server.sh
diff --git a/plugins/examples/nemocheck/uv.lock b/plugins/examples/nemocheck_external/uv.lock
similarity index 100%
rename from plugins/examples/nemocheck/uv.lock
rename to plugins/examples/nemocheck_external/uv.lock
diff --git a/plugins/examples/nemocheckinternal/README.md b/plugins/examples/nemocheckinternal/README.md
deleted file mode 100644
index 0e874e2..0000000
--- a/plugins/examples/nemocheckinternal/README.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# Internal NemoCheck Plugin
-
-## Prerequisites: Nemo-check server
- * Refer to [orignal repo](https://github.com/m-misiura/demos/tree/main/nemo_openshift/guardrail-checks/deployment) for full instructions
- * Instructions adpated for mcpgateway kind cluster to work with an llm proxy routing to some open ai compatable backend below
-
- ```bash
- docker pull quay.io/rh-ee-mmisiura/nemo-guardrails:guardrails_checks_with_tools_o1_v1
- kind load docker-image quay.io/rh-ee-mmisiura/nemo-guardrails:guardrails_checks_with_tools_o1_v1 --name mcp-gateway
- cd plugins-adapter/plugins/examples/nemocheck/k8deploy
- kubectl apply -f config-tools.yaml
- kubectl apply -f server.yaml
-
- ```
-## Installation
-
-1. Find url of nemo-check-server service. E.g., from svc in `server.yaml`
-1. Update `${project_root}/resources/config/config.yaml`. Add the blob below, merge if other `plugin`s or `plugin_dir`s already exists. Sample file [here](/resources/config/nemocheck-internal-config.yaml)
-
- ```yaml
- # plugins/config.yaml - Main plugin configuration file
- plugins:
- - name: "NemoCheckv2"
- kind: "plugins.examples.nemocheckinternal.plugin.NemoCheckv2"
- description: "Adapter for nemo check server"
- version: "0.1.0"
- hooks: ["tool_pre_invoke", "tool_post_invoke"]
- mode: "enforce" # enforce | permissive | disabled
- config:
- checkserver_url: "http://nemo-guardrails-service:8000/v1/guardrail/checks"
- # Plugin directories to scan
- plugin_dirs:
- - "plugins/examples/nemocheckinternal" # Nemo Check Server plugins
- ```
-
-1. In `config.yaml` ensure key `plugins.config.checkserver_url` points to the correct service
-1. Start plugin adapter
-
-# Test
-
-1. Open mcp-inspector to the mcp-gateway
-1. Try running a tool configured/not configured in nemo check config allow list in configmap [E.g.](/plugins/examples/nemocheck/k8deploy/config-tools.yaml)
\ No newline at end of file
diff --git a/plugins/examples/nemocheckinternal/__init__.py b/plugins/examples/nemocheckinternal/__init__.py
deleted file mode 100644
index 16e5be5..0000000
--- a/plugins/examples/nemocheckinternal/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""MCP Gateway NemoCheckv2 Plugin - Nemo Check Adapter.
-
-Copyright 2025
-SPDX-License-Identifier: Apache-2.0
-Authors: julianstephen
-
-"""
From 9d8eaa7a51518bf3e8bd593d09268a6225921274 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Fri, 13 Feb 2026 13:10:52 -0700
Subject: [PATCH 12/27] :truck::recycle: Move around deploy files and
references
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
plugins/examples/README.md | 14 +--
plugins/examples/nemocheck/README.md | 87 ++++++++++---------
plugins/examples/nemocheck/config.yaml | 2 +-
.../k8deploy/Makefile | 0
.../k8deploy/config-tools.yaml | 0
.../k8deploy/server.yaml | 0
plugins/examples/nemocheck/plugin.py | 10 ++-
plugins/examples/nemocheck/tests/__init__.py | 2 +-
plugins/examples/nemocheck_external/README.md | 71 +++------------
resources/config/config.yaml | 17 ++--
.../config/nemocheck-internal-config.yaml | 14 +--
11 files changed, 93 insertions(+), 124 deletions(-)
rename plugins/examples/{nemocheck_external => nemocheck}/k8deploy/Makefile (100%)
rename plugins/examples/{nemocheck_external => nemocheck}/k8deploy/config-tools.yaml (100%)
rename plugins/examples/{nemocheck_external => nemocheck}/k8deploy/server.yaml (100%)
diff --git a/plugins/examples/README.md b/plugins/examples/README.md
index 7bc87b0..c857d33 100644
--- a/plugins/examples/README.md
+++ b/plugins/examples/README.md
@@ -10,16 +10,16 @@ Internal plugin that wraps NeMo Guardrails for PII detection using an Ollama mod
- See [nemo/README.md](./nemo/README.md) for details
### nemocheck
-External plugin adapter for NeMo Guardrails check server
-- **Type**: External (separate service)
-- Requires separate NeMo check server deployment
-- See [nemocheck/README.md](./nemocheck/README.md) for details
-
-### nemocheck-internal
Internal plugin adapter for NeMo Guardrails check server
- **Type**: Internal
- Requires separate NeMo check server deployment
-- See [README.md](./nemocheckinternal/README.md) for details
+- See [README.md](./nemocheck/README.md) for details
+
+### nemocheck_external
+External plugin adapter for NeMo Guardrails check server
+- **Type**: External (separate service)
+- Requires separate NeMo check server deployment
+- See [nemocheck_external/README.md](./nemocheck_external/README.md) for details
## Usage
diff --git a/plugins/examples/nemocheck/README.md b/plugins/examples/nemocheck/README.md
index eb064ef..f586c81 100644
--- a/plugins/examples/nemocheck/README.md
+++ b/plugins/examples/nemocheck/README.md
@@ -4,16 +4,15 @@ This directory contains the core `NemoCheck` plugin implementation used by both
## Prerequisites: Nemo-check server
* Refer to [orignal repo](https://github.com/m-misiura/demos/tree/main/nemo_openshift/guardrail-checks/deployment) for full instructions
- * Instructions adpated for mcpgateway kind cluster to work with an llm proxy routing to some open ai compatable backend below
+ * Instructions are adapted for the `mcp-gateway` kind cluster to work with an LLM proxy routing to an OpenAI-compatible backend below:
- ```bash
- docker pull quay.io/rh-ee-mmisiura/nemo-guardrails:guardrails_checks_with_tools_o1_v1
- kind load docker-image quay.io/rh-ee-mmisiura/nemo-guardrails:guardrails_checks_with_tools_o1_v1 --name mcp-gateway
- cd plugins-adapter/plugins/examples/nemocheck/k8deploy
- kubectl apply -f config-tools.yaml
- kubectl apply -f server.yaml
+```bash
+docker pull quay.io/rh-ee-mmisiura/nemo-guardrails:guardrails_checks_with_tools_o1_v1
+kind load docker-image quay.io/rh-ee-mmisiura/nemo-guardrails:guardrails_checks_with_tools_o1_v1 --name mcp-gateway
+cd plugins-adapter/plugins/examples/nemocheck/k8deploy
+make deploy
+```
- ```
## Installation
1. Find url of nemo-check-server service. E.g., from svc in `server.yaml`
@@ -23,7 +22,7 @@ This directory contains the core `NemoCheck` plugin implementation used by both
# plugins/config.yaml - Main plugin configuration file
plugins:
- name: "NemoCheck"
- kind: "plugins.examples.nemocheck.nemocheck.plugin.NemoCheck"
+ kind: "plugins.examples.nemocheck.plugin.NemoCheck"
description: "Adapter for nemo check server"
version: "0.1.0"
hooks: ["tool_pre_invoke", "tool_post_invoke"]
@@ -38,46 +37,56 @@ This directory contains the core `NemoCheck` plugin implementation used by both
1. In `config.yaml` ensure key `plugins.config.checkserver_url` points to the correct service
1. Start plugin adapter
-## Plugin Development
-
-To install dependencies with dev packages (required for linting and testing):
-
-```bash
-make install-dev
-```
-
-Alternatively, you can also install it in editable mode:
-
-```bash
-make install-editable
-```
-
-## Setting up the development environment
-
-1. Copy .env.template .env
-2. Enable plugins in `.env`
-
## Testing
Test modules are created under the `tests` directory.
-To run all tests, use the following command:
+To run all tests:
```bash
-make test
+python -m pytest tests/ -v
```
**Note:** To enable logging, set `log_cli = true` in `tests/pytest.ini`.
-## Code Linting
-
-Before checking in any code for the project, please lint the code. This can be done using:
-
-```bash
-make lint-fix
+## Test with MCP inspector
+ * Add allowed tools to `plugins-adapter/plugins/examples/nemocheck/k8deploy/config-tools.yaml#check_tool_call_safety`
+
+
+| config-tools.yaml line-127 |
+Updated to add test2_hello_world |
+
+
+
+
+
+```python
+@action(is_system_action=True)
+async def check_tool_call_safety(tool_calls=None, context=None):
+ """Allow list for tool execution."""
+ ...
+ allowed_tools = ["get_weather", "search_web",
+ "get_time", "slack_read_messages"]
+ ...
```
+
+ |
+
+
+```python
+@action(is_system_action=True)
+async def check_tool_call_safety(tool_calls=None, context=None):
+ """Allow list for tool execution."""
+ ...
+ allowed_tools = ["get_weather", "search_web", "get_time",
+ "test2_hello_world", "slack_read_messages"]
+ ...
+```
+
+ |
+
+
-# Test
-1. Open mcp-inspector to the mcp-gateway
-1. Try running a tool configured/not configured in nemo check config allow list in configmap [E.g.](/plugins/examples/nemocheck/k8deploy/config-tools.yaml)
+ * Redeploy check server
+ * Open the MCP inspector provided by the MCP gateway. Try tools in the allow-list vs. tools not in the allow-list.
diff --git a/plugins/examples/nemocheck/config.yaml b/plugins/examples/nemocheck/config.yaml
index d9f97b1..89b047f 100644
--- a/plugins/examples/nemocheck/config.yaml
+++ b/plugins/examples/nemocheck/config.yaml
@@ -1,6 +1,6 @@
plugins:
- name: "NemoCheck"
- kind: "plugins.examples.nemocheckinternal.plugin.NemoCheckv2"
+ kind: "plugins.examples.nemocheck.plugin.NemoCheck"
description: "Nemo Check Adapter"
version: "0.1.0"
author: "julianstephen"
diff --git a/plugins/examples/nemocheck_external/k8deploy/Makefile b/plugins/examples/nemocheck/k8deploy/Makefile
similarity index 100%
rename from plugins/examples/nemocheck_external/k8deploy/Makefile
rename to plugins/examples/nemocheck/k8deploy/Makefile
diff --git a/plugins/examples/nemocheck_external/k8deploy/config-tools.yaml b/plugins/examples/nemocheck/k8deploy/config-tools.yaml
similarity index 100%
rename from plugins/examples/nemocheck_external/k8deploy/config-tools.yaml
rename to plugins/examples/nemocheck/k8deploy/config-tools.yaml
diff --git a/plugins/examples/nemocheck_external/k8deploy/server.yaml b/plugins/examples/nemocheck/k8deploy/server.yaml
similarity index 100%
rename from plugins/examples/nemocheck_external/k8deploy/server.yaml
rename to plugins/examples/nemocheck/k8deploy/server.yaml
diff --git a/plugins/examples/nemocheck/plugin.py b/plugins/examples/nemocheck/plugin.py
index 598bb4e..46a762c 100644
--- a/plugins/examples/nemocheck/plugin.py
+++ b/plugins/examples/nemocheck/plugin.py
@@ -26,17 +26,19 @@
import logging
import os
import requests
+import json
# Initialize logging
logger = logging.getLogger(__name__)
log_level = os.getenv("LOGLEVEL", "INFO").upper()
logger.setLevel(log_level)
-MODEL_NAME = os.getenv("NEMO_MODEL", "meta-llama/llama-3-3-70b-instruct")
+MODEL_NAME = os.getenv(
+ "NEMO_MODEL", "meta-llama/llama-3-3-70b-instruct"
+) # Currently only for logging.
DEFAULT_CHECK_ENDPOINT = os.getenv(
"CHECK_ENDPOINT", "http://nemo-guardrails-service:8000"
)
-
HEADERS = {
"Content-Type": "application/json",
}
@@ -138,8 +140,8 @@ async def tool_pre_invoke(
else:
metadata = data.get("rails_status")
violation = PluginViolation(
- reason=f"Tool Check status: {status}",
- description="Rails check blocked request",
+ reason=f"Check tool rails:{status}.",
+ description=json.dumps(data),
code=f"checkserver_http_status_code:{response.status_code}",
details=metadata,
)
diff --git a/plugins/examples/nemocheck/tests/__init__.py b/plugins/examples/nemocheck/tests/__init__.py
index 53386c4..f4fbc39 100644
--- a/plugins/examples/nemocheck/tests/__init__.py
+++ b/plugins/examples/nemocheck/tests/__init__.py
@@ -1 +1 @@
-"""Tests for nemocheckinternal plugin."""
+"""Tests for nemocheck plugin."""
diff --git a/plugins/examples/nemocheck_external/README.md b/plugins/examples/nemocheck_external/README.md
index 53032ee..94cf97f 100644
--- a/plugins/examples/nemocheck_external/README.md
+++ b/plugins/examples/nemocheck_external/README.md
@@ -6,13 +6,11 @@ This is an external plugin deployment for the NemoCheck guardrails adapter. It r
- **Core Implementation**: `plugins/examples/nemocheck/plugin.py` contains the actual NemoCheck logic
- **External Deployment**: This directory (`nemocheck_external`) configures and deploys the plugin as an external MCP server
-- **Shared Logic**: Both internal and external deployments use the same underlying implementation to eliminate code duplication
-
## Run plugin in kind cluster
- 1. Run Nemo Guardrails check server. Instructions [here](#Deploy-checkserver)
- 1. Update `CHECK_ENDPOINT` variable in k8deploy/deploy.yaml to point to guardrails check server endpoint
+ 1. Run Nemo Guardrails check server. Instructions are the same as in the internal plugin [here](../nemocheck/README.md#prerequisites-nemo-check-server)
+ 1. Update `CHECK_ENDPOINT` variable in [k8deploy/deploy.yaml](./k8deploy/deploy.yaml) to point to guardrails check server endpoint
```bash
cd plugins-adapter/plugins/examples/nemocheck
@@ -39,66 +37,25 @@ This is an external plugin deployment for the NemoCheck guardrails adapter. It r
make all
```
-## Test with MCP inspector
- * Add allowed tools to `plugins-adapter/plugins/examples/nemocheck/k8deploy/config-tools.yaml#check_tool_call_safety`
-
-
-| config-tools.yaml line-127 |
-Updated to add test2_hello_world |
-
-
-
-
-
-```python
-@action(is_system_action=True)
-async def check_tool_call_safety(tool_calls=None, context=None):
- """Allow list for tool execution."""
- ...
- allowed_tools = ["get_weather", "search_web",
- "get_time", "slack_read_messages"]
- ...
-```
-
- |
-
-
-```python
-@action(is_system_action=True)
-async def check_tool_call_safety(tool_calls=None, context=None):
- """Allow list for tool execution."""
- ...
- allowed_tools = ["get_weather", "search_web", "get_time",
- "test2_hello_world", "slack_read_messages"]
- ...
-```
-
- |
-
-
+## Plugin Development
- * Redeploy check server
- * Open mcp inspector. Try tools in allow list vs tools not in allow list
+To install dependencies with dev packages (required for linting and testing):
+```bash
+make install-dev
+```
-## Deploy-checkserver
- * Refer to [orignal repo](https://github.com/m-misiura/demos/tree/main/nemo_openshift/guardrail-checks/deployment) for full instructions
- * Instructions adpated for mcpgateway kind cluster to work with an llm proxy routing to some open ai compatable backend below
- * Makefile has targets to load checkserver to kind cluster, etc.
-
- ```bash
- cd plugins-adapter/plugins/examples/nemocheck/k8deploy
- make deploy
-
- ```
-
+Alternatively, you can also install it in editable mode:
-## Testing
+```bash
+make install-editable
+```
-Tests are located in the `nemocheck` plugin directory since both deployments share the same core implementation.
+## Setting up the development environment
-See the [nemocheck README](../nemocheck/README.md#testing) for testing instructions.
+1. Copy .env.template .env
+2. Enable plugins in `.env`
## Runtime (server)
diff --git a/resources/config/config.yaml b/resources/config/config.yaml
index 4b1530f..13a354b 100644
--- a/resources/config/config.yaml
+++ b/resources/config/config.yaml
@@ -37,20 +37,21 @@ plugins:
foo: bar
# Nemo Check Example
- - name: "NemoCheckv2"
- kind: "plugins.examples.nemocheckinternal.plugin.NemoCheckv2"
+ - name: "NemoCheck"
+ kind: "plugins.examples.nemocheck.plugin.NemoCheck"
description: "Adapter for nemo check server"
version: "0.1.0"
author: "Julian Stephen"
config:
- checkserver_url: "http://nemo-guardrails-service:8000/v1/guardrail/checks"
+ checkserver_url: "http://nemo-guardrails-service:8000/v1/guardrail/checks"
+
# Plugin directories to scan
plugin_dirs:
- - "plugins/native" # Built-in plugins
- - "plugins/custom" # Custom organization plugins
- - "/etc/mcpgateway/plugins" # System-wide plugins
- - "plugins/examples/nemo" # Example Nemo guardrails plugins
- - "plugins/examples/nemocheckinternal" # Nemo Check Server plugins
+ - "plugins/native" # Built-in plugins
+ - "plugins/custom" # Custom organization plugins
+ - "/etc/mcpgateway/plugins" # System-wide plugins
+ - "plugins/examples/nemo" # Example Nemo guardrails plugins
+ - "plugins/examples/nemocheck" # Nemo Check Server plugins
# Global plugin settings
plugin_settings:
diff --git a/resources/config/nemocheck-internal-config.yaml b/resources/config/nemocheck-internal-config.yaml
index 8650cb1..ccf5238 100644
--- a/resources/config/nemocheck-internal-config.yaml
+++ b/resources/config/nemocheck-internal-config.yaml
@@ -1,8 +1,8 @@
# plugins/config.yaml - Main plugin configuration file
plugins:
# Nemo Check Example
- - name: "NemoCheckv2"
- kind: "plugins.examples.nemocheckinternal.plugin.NemoCheckv2"
+ - name: "NemoCheck"
+ kind: "plugins.examples.nemocheck.plugin.NemoCheck"
description: "Adapter for nemo check server"
version: "0.1.0"
author: "Julian Stephen"
@@ -15,11 +15,11 @@ plugins:
# Plugin directories to scan
plugin_dirs:
- - "plugins/native" # Built-in plugins
- - "plugins/custom" # Custom organization plugins
- - "/etc/mcpgateway/plugins" # System-wide plugins
- - "plugins/examples/nemo" # Example Nemo guardrails plugins
- - "plugins/examples/nemocheckinternal" # Nemo Check Server plugins
+ - "plugins/native" # Built-in plugins
+ - "plugins/custom" # Custom organization plugins
+ - "/etc/mcpgateway/plugins" # System-wide plugins
+ - "plugins/examples/nemo" # Example Nemo guardrails plugins
+ - "plugins/examples/nemocheck" # Nemo Check Server plugins
# Global plugin settings
plugin_settings:
From 700a6966aa68798cad2a3b23789762ea0d8c2c85 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Fri, 13 Feb 2026 13:15:44 -0700
Subject: [PATCH 13/27] :art: Lint test
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
plugins/examples/nemocheck/tests/test_all.py | 20 +++++++++++++++-----
1 file changed, 15 insertions(+), 5 deletions(-)
diff --git a/plugins/examples/nemocheck/tests/test_all.py b/plugins/examples/nemocheck/tests/test_all.py
index 85d1c22..3212f77 100644
--- a/plugins/examples/nemocheck/tests/test_all.py
+++ b/plugins/examples/nemocheck/tests/test_all.py
@@ -33,9 +33,13 @@ def plugin_manager():
async def test_prompt_pre_hook(plugin_manager: PluginManager):
"""Test prompt pre hook across all registered plugins."""
# Customize payload for testing
- payload = PromptPrehookPayload(prompt_id="test_prompt", args={"arg0": "This is an argument"})
+ payload = PromptPrehookPayload(
+ prompt_id="test_prompt", args={"arg0": "This is an argument"}
+ )
global_context = GlobalContext(request_id="1")
- result, _ = await plugin_manager.invoke_hook(PromptHookType.PROMPT_PRE_FETCH, payload, global_context)
+ result, _ = await plugin_manager.invoke_hook(
+ PromptHookType.PROMPT_PRE_FETCH, payload, global_context
+ )
# Assert expected behaviors
assert result.continue_processing
@@ -48,7 +52,9 @@ async def test_prompt_post_hook(plugin_manager: PluginManager):
prompt_result = PromptResult(messages=[message])
payload = PromptPosthookPayload(prompt_id="test_prompt", result=prompt_result)
global_context = GlobalContext(request_id="1")
- result, _ = await plugin_manager.invoke_hook(PromptHookType.PROMPT_POST_FETCH, payload, global_context)
+ result, _ = await plugin_manager.invoke_hook(
+ PromptHookType.PROMPT_POST_FETCH, payload, global_context
+ )
# Assert expected behaviors
assert result.continue_processing
@@ -57,8 +63,12 @@ async def test_prompt_post_hook(plugin_manager: PluginManager):
async def test_tool_post_hook(plugin_manager: PluginManager):
"""Test tool post hook across all registered plugins."""
# Customize payload for testing
- payload = ToolPostInvokePayload(name="test_tool", result={"output0": "output value"})
+ payload = ToolPostInvokePayload(
+ name="test_tool", result={"output0": "output value"}
+ )
global_context = GlobalContext(request_id="1")
- result, _ = await plugin_manager.invoke_hook(ToolHookType.TOOL_POST_INVOKE, payload, global_context)
+ result, _ = await plugin_manager.invoke_hook(
+ ToolHookType.TOOL_POST_INVOKE, payload, global_context
+ )
# Assert expected behaviors
assert result.continue_processing
From e0b45d773dd5e1822dd33fac9b5aecebc9de0a9b Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Fri, 13 Feb 2026 13:20:16 -0700
Subject: [PATCH 14/27] :construction_worker: Update CI references
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
.github/workflows/ci.yaml | 10 ++++------
1 file changed, 4 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 7536ae2..7c3c726 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -42,13 +42,11 @@ jobs:
git diff --stat
exit 1
}
- - name: Install nemocheck external plugin dependencies
+ - name: Install nemocheck plugin dependencies
working-directory: ./plugins/examples/nemocheck
run: |
- echo "Running nemocheck ext plugin tests.."
+ echo "Running nemocheck plugin tests.."
uv sync --all-groups
- - name: Run nemocheck external plugin tests
+ - name: Run nemocheck plugin tests
working-directory: ./plugins/examples/nemocheck
- run: uv run pytest tests
-
- # - name: Test
+ run: uv run pytest tests
From b229dbf48bb9b88d13389624d9c1d305146a0bbd Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Fri, 13 Feb 2026 13:28:06 -0700
Subject: [PATCH 15/27] :truck::white_check_mark: Update moved nemo check tests
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
plugins/examples/{nemocheck_external => nemocheck}/MANIFEST.in | 0
.../examples/{nemocheck_external => nemocheck}/pyproject.toml | 0
plugins/examples/nemocheck/tests/pytest.ini | 2 +-
plugins/examples/{nemocheck_external => nemocheck}/uv.lock | 0
4 files changed, 1 insertion(+), 1 deletion(-)
rename plugins/examples/{nemocheck_external => nemocheck}/MANIFEST.in (100%)
rename plugins/examples/{nemocheck_external => nemocheck}/pyproject.toml (100%)
rename plugins/examples/{nemocheck_external => nemocheck}/uv.lock (100%)
diff --git a/plugins/examples/nemocheck_external/MANIFEST.in b/plugins/examples/nemocheck/MANIFEST.in
similarity index 100%
rename from plugins/examples/nemocheck_external/MANIFEST.in
rename to plugins/examples/nemocheck/MANIFEST.in
diff --git a/plugins/examples/nemocheck_external/pyproject.toml b/plugins/examples/nemocheck/pyproject.toml
similarity index 100%
rename from plugins/examples/nemocheck_external/pyproject.toml
rename to plugins/examples/nemocheck/pyproject.toml
diff --git a/plugins/examples/nemocheck/tests/pytest.ini b/plugins/examples/nemocheck/tests/pytest.ini
index ff60648..e11e911 100644
--- a/plugins/examples/nemocheck/tests/pytest.ini
+++ b/plugins/examples/nemocheck/tests/pytest.ini
@@ -8,6 +8,6 @@ log_format = %(asctime)s [%(module)s] [%(levelname)s] %(message)s
log_date_format = %Y-%m-%d %H:%M:%S
addopts = --cov --cov-report term-missing
env_files = .env
-pythonpath = . src
+pythonpath = .. . src
filterwarnings =
ignore::DeprecationWarning:pydantic.*
diff --git a/plugins/examples/nemocheck_external/uv.lock b/plugins/examples/nemocheck/uv.lock
similarity index 100%
rename from plugins/examples/nemocheck_external/uv.lock
rename to plugins/examples/nemocheck/uv.lock
From 33b35654fa4787d318e0f6a176bd6892e2849fcd Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Fri, 13 Feb 2026 13:57:32 -0700
Subject: [PATCH 16/27] :wrench: Add hooks to config
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
resources/config/config.yaml | 1 +
1 file changed, 1 insertion(+)
diff --git a/resources/config/config.yaml b/resources/config/config.yaml
index 13a354b..2b4b048 100644
--- a/resources/config/config.yaml
+++ b/resources/config/config.yaml
@@ -40,6 +40,7 @@ plugins:
- name: "NemoCheck"
kind: "plugins.examples.nemocheck.plugin.NemoCheck"
description: "Adapter for nemo check server"
+ hooks: ["tool_pre_invoke", "tool_post_invoke"]
version: "0.1.0"
author: "Julian Stephen"
config:
From 6ede34f46caceac1fca5e0712d01ca8ba3164eb5 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Fri, 13 Feb 2026 15:31:51 -0700
Subject: [PATCH 17/27] :wrench: Updates for deploying external plugin
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
.../examples/nemocheck_external/Containerfile | 13 ++-
plugins/examples/nemocheck_external/Makefile | 7 +-
plugins/examples/nemocheck_external/README.md | 7 +-
.../nemocheck_external/k8deploy/deploy.yaml | 12 +-
.../nemocheck_external/pyproject.toml | 106 ++++++++++++++++++
.../resources/plugins/config.yaml | 4 +-
6 files changed, 134 insertions(+), 15 deletions(-)
create mode 100644 plugins/examples/nemocheck_external/pyproject.toml
diff --git a/plugins/examples/nemocheck_external/Containerfile b/plugins/examples/nemocheck_external/Containerfile
index 8652213..a834181 100644
--- a/plugins/examples/nemocheck_external/Containerfile
+++ b/plugins/examples/nemocheck_external/Containerfile
@@ -9,6 +9,7 @@ ARG VERSION
ENV APP_HOME=/app
ENV PLUGINS_TRANSPORT=http
+ENV PYTHONPATH="${HOME}:${PYTHONPATH}"
USER 0
@@ -27,8 +28,16 @@ RUN mkdir -p ${APP_HOME} && \
USER 1001
# Install plugin package
-COPY . .
-RUN pip install --no-cache-dir uv && python -m uv pip install .
+# Note: Build context should be set to parent directory to access ../nemocheck
+# Example: docker build -f nemocheck_external/Containerfile -t nemocheck-external .
+COPY --chown=1001:0 nemocheck ${HOME}/nemocheck
+COPY --chown=1001:0 nemocheck_external ${HOME}/
+
+WORKDIR ${HOME}
+# Install nemocheck package first (the core plugin)
+RUN pip install --no-cache-dir uv && \
+ cd nemocheck && python -m uv pip install . && cd .. && \
+ python -m uv pip install .
# Make default cache directory writable
RUN mkdir -p -m 0776 ${HOME}/.cache
diff --git a/plugins/examples/nemocheck_external/Makefile b/plugins/examples/nemocheck_external/Makefile
index 6b33011..9f8a139 100644
--- a/plugins/examples/nemocheck_external/Makefile
+++ b/plugins/examples/nemocheck_external/Makefile
@@ -115,9 +115,10 @@ PLATFORM ?= linux/$(shell uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
container-build:
@echo "🔨 Building with $(CONTAINER_RUNTIME) for platform $(PLATFORM)..."
- $(CONTAINER_RUNTIME) build \
+ @echo "📁 Using parent directory as build context to include nemocheck plugin..."
+ cd .. && $(CONTAINER_RUNTIME) build \
--platform=$(PLATFORM) \
- -f $(CONTAINER_FILE) \
+ -f nemocheck_external/$(CONTAINER_FILE) \
--tag $(IMAGE_BASE):$(IMAGE_TAG) \
.
@echo "✅ Built image: $(call get_image_name)"
@@ -418,7 +419,7 @@ load: container-build
kind load docker-image $(IMAGE_BASE):$(IMAGE_TAG) --name mcp-gateway
.PHONY: redeploy
-redeploy:
+redeploy:
-kubectl delete -f k8deploy/deploy.yaml
kubectl apply -f k8deploy/deploy.yaml
diff --git a/plugins/examples/nemocheck_external/README.md b/plugins/examples/nemocheck_external/README.md
index 94cf97f..e32c59b 100644
--- a/plugins/examples/nemocheck_external/README.md
+++ b/plugins/examples/nemocheck_external/README.md
@@ -6,6 +6,8 @@ This is an external plugin deployment for the NemoCheck guardrails adapter. It r
- **Core Implementation**: `plugins/examples/nemocheck/plugin.py` contains the actual NemoCheck logic
- **External Deployment**: This directory (`nemocheck_external`) configures and deploys the plugin as an external MCP server
+- **Shared Logic**: The config references `nemocheck` to use the core implementation without code duplication
+- **Container Build**: The Containerfile copies the `nemocheck` directory during build to include the core implementation
## Run plugin in kind cluster
@@ -13,7 +15,7 @@ This is an external plugin deployment for the NemoCheck guardrails adapter. It r
1. Update `CHECK_ENDPOINT` variable in [k8deploy/deploy.yaml](./k8deploy/deploy.yaml) to point to guardrails check server endpoint
```bash
- cd plugins-adapter/plugins/examples/nemocheck
+ cd plugins-adapter/plugins/examples/nemocheck_external
make deploy
```
1.
@@ -21,7 +23,8 @@ This is an external plugin deployment for the NemoCheck guardrails adapter. It r
Non-kind k8 cluster instructions
```bash
- cd plugins-adapter/plugins/examples/nemocheck
+ cd plugins-adapter/plugins/examples/nemocheck_external
+ make deploy
make container-build
# push image to your container repo and update image name in k8deploy/deploy.yaml
kubectl apply -f k8deploy/deploy.yaml
diff --git a/plugins/examples/nemocheck_external/k8deploy/deploy.yaml b/plugins/examples/nemocheck_external/k8deploy/deploy.yaml
index 5354549..4ae0703 100644
--- a/plugins/examples/nemocheck_external/k8deploy/deploy.yaml
+++ b/plugins/examples/nemocheck_external/k8deploy/deploy.yaml
@@ -39,17 +39,17 @@ spec:
value: "0.0.0.0"
- name: PLUGINS_ENABLED
value: "false"
- - name: PLUGINS_CLI_COMPLETION
+ - name: PLUGINS_CLI_COMPLETION
value: "false"
- - name: PLUGINS_CLI_MARKUP_MODE
+ - name: PLUGINS_CLI_MARKUP_MODE
value: "rich"
- - name: PLUGINS_CONFIG
+ - name: PLUGINS_CONFIG
value: "./resources/plugins/config.yaml"
- # - name: CHUK_MCP_CONFIG_PATH
+ # - name: CHUK_MCP_CONFIG_PATH
# value: "./resources/runtime/config.yaml"
- - name: MCP_SSL_ENABLED
+ - name: MCP_SSL_ENABLED
value: "false"
- - name: MCP_SSL_CERT_REQS
+ - name: MCP_SSL_CERT_REQS
value: "0"
- name: LOGLEVEL
value: "DEBUG"
diff --git a/plugins/examples/nemocheck_external/pyproject.toml b/plugins/examples/nemocheck_external/pyproject.toml
new file mode 100644
index 0000000..142de73
--- /dev/null
+++ b/plugins/examples/nemocheck_external/pyproject.toml
@@ -0,0 +1,106 @@
+# ----------------------------------------------------------------
+# 💡 Build system (PEP 517)
+# - setuptools ≥ 77 gives SPDX licence support (PEP 639)
+# - wheel is needed by most build front-ends
+# ----------------------------------------------------------------
+[build-system]
+requires = ["setuptools>=77", "wheel"]
+build-backend = "setuptools.build_meta"
+
+# ----------------------------------------------------------------
+# 📦 Core project metadata (PEP 621)
+# ----------------------------------------------------------------
+[project]
+name = "nemocheck_external"
+version = "0.1.0"
+description = "Nemo-Check guardrails external"
+keywords = ["MCP","API","gateway","tools",
+ "agents","agentic ai","model context protocol","multi-agent","fastapi",
+ "json-rpc","sse","websocket","federation","security","authentication"
+]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Framework :: FastAPI",
+ "Framework :: AsyncIO",
+ "Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
+ "Topic :: Software Development :: Libraries :: Application Frameworks"
+]
+readme = "README.md"
+requires-python = ">=3.11,<3.14"
+license = "Apache-2.0"
+license-files = ["LICENSE"]
+
+maintainers = [
+ {name = "julianstephen", email = "julian.stephen@gmail.com"}
+]
+
+authors = [
+ {name = "julianstephen", email = "julian.stephen@gmail.com"}
+]
+
+dependencies = [
+ "mcp>=1.16.0",
+ "mcp-contextforge-gateway",
+]
+
+# URLs
+[project.urls]
+Homepage = "https://ibm.github.io/mcp-context-forge/"
+Documentation = "https://ibm.github.io/mcp-context-forge/"
+Repository = "https://github.com/IBM/mcp-context-forge"
+"Bug Tracker" = "https://github.com/IBM/mcp-context-forge/issues"
+Changelog = "https://github.com/IBM/mcp-context-forge/blob/main/CHANGELOG.md"
+
+[tool.uv.sources]
+mcp-contextforge-gateway = { git = "https://github.com/IBM/mcp-context-forge.git", rev = "main" }
+
+# ----------------------------------------------------------------
+# Optional dependency groups (extras)
+# ----------------------------------------------------------------
+[project.optional-dependencies]
+dev = [
+ "black>=25.1.0",
+ "pytest>=8.4.1",
+ "pytest-asyncio>=1.1.0",
+ "pytest-cov>=6.2.1",
+ "pytest-dotenv>=0.5.2",
+ "pytest-env>=1.1.5",
+ "pytest-examples>=0.0.18",
+ "pytest-md-report>=0.7.0",
+ "pytest-rerunfailures>=15.1",
+ "pytest-trio>=0.8.0",
+ "pytest-xdist>=3.8.0",
+ "ruff>=0.12.9",
+ "unimport>=1.2.1",
+ "uv>=0.8.11",
+]
+
+# --------------------------------------------------------------------
+# 🔧 setuptools-specific configuration
+# --------------------------------------------------------------------
+[tool.setuptools]
+include-package-data = true # ensure wheels include the data files
+
+# Automatic discovery: keep every package that starts with "nemocheck_external"
+[tool.setuptools.packages.find]
+include = ["nemocheck_external*"]
+exclude = ["tests*"]
+
+## Runtime data files ------------------------------------------------
+[tool.setuptools.package-data]
+nemocheck_external = [
+ "resources/plugins/config.yaml",
+]
+
+[dependency-groups]
+dev = [
+ "pytest>=8.4.2",
+ "pytest-asyncio>=1.3.0",
+ "pytest-cov>=7.0.0",
+ "ruff>=0.14.14",
+]
diff --git a/plugins/examples/nemocheck_external/resources/plugins/config.yaml b/plugins/examples/nemocheck_external/resources/plugins/config.yaml
index bf3f0db..d4abf3b 100644
--- a/plugins/examples/nemocheck_external/resources/plugins/config.yaml
+++ b/plugins/examples/nemocheck_external/resources/plugins/config.yaml
@@ -1,6 +1,6 @@
plugins:
- name: "NemoCheck"
- kind: "plugin.NemoCheck"
+ kind: "nemocheck.plugin.NemoCheck"
description: "Adapter for Nemo-Check guardrails"
version: "0.1.0"
author: "julianstephen"
@@ -17,7 +17,7 @@ plugins:
# Plugin directories to scan
plugin_dirs:
- - "../nemocheck"
+ - "."
# Global plugin settings
plugin_settings:
From 580262f08b6c63bffa300811083c7b00da260723 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Fri, 13 Feb 2026 15:33:28 -0700
Subject: [PATCH 18/27] :goal_net: Add error handling for empty config
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
plugins/examples/nemocheck/plugin.py | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/plugins/examples/nemocheck/plugin.py b/plugins/examples/nemocheck/plugin.py
index 46a762c..1751590 100644
--- a/plugins/examples/nemocheck/plugin.py
+++ b/plugins/examples/nemocheck/plugin.py
@@ -37,7 +37,7 @@
"NEMO_MODEL", "meta-llama/llama-3-3-70b-instruct"
) # Currently only for logging.
DEFAULT_CHECK_ENDPOINT = os.getenv(
- "CHECK_ENDPOINT", "http://nemo-guardrails-service:8000"
+ "CHECK_ENDPOINT", "http://nemo-guardrails-service:8000/v1/guardrail/checks"
)
HEADERS = {
"Content-Type": "application/json",
@@ -55,9 +55,14 @@ def __init__(self, config: PluginConfig):
"""
super().__init__(config)
# Allow config to override the endpoint
- self.check_endpoint = config.config.get(
- "checkserver_url", DEFAULT_CHECK_ENDPOINT
- )
+ # Handle case where config.config might be None or empty
+ if config.config and isinstance(config.config, dict):
+ self.check_endpoint = config.config.get(
+ "checkserver_url", DEFAULT_CHECK_ENDPOINT
+ )
+ else:
+ self.check_endpoint = DEFAULT_CHECK_ENDPOINT
+ logger.warning("Plugin config is empty or invalid, using default endpoint")
logger.info(f"Nemo Check endpoint: {self.check_endpoint}")
async def prompt_pre_fetch(
From aee2006772d37849a554ec8ef0f535b24515c994 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Fri, 13 Feb 2026 15:41:19 -0700
Subject: [PATCH 19/27] :fire: Minimal pyproject
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
.../nemocheck_external/pyproject.toml | 81 +------------------
1 file changed, 4 insertions(+), 77 deletions(-)
diff --git a/plugins/examples/nemocheck_external/pyproject.toml b/plugins/examples/nemocheck_external/pyproject.toml
index 142de73..4827824 100644
--- a/plugins/examples/nemocheck_external/pyproject.toml
+++ b/plugins/examples/nemocheck_external/pyproject.toml
@@ -13,94 +13,21 @@ build-backend = "setuptools.build_meta"
[project]
name = "nemocheck_external"
version = "0.1.0"
-description = "Nemo-Check guardrails external"
-keywords = ["MCP","API","gateway","tools",
- "agents","agentic ai","model context protocol","multi-agent","fastapi",
- "json-rpc","sse","websocket","federation","security","authentication"
-]
-classifiers = [
- "Development Status :: 4 - Beta",
- "Intended Audience :: Developers",
- "Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.11",
- "Programming Language :: Python :: 3.12",
- "Programming Language :: Python :: 3.13",
- "Framework :: FastAPI",
- "Framework :: AsyncIO",
- "Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
- "Topic :: Software Development :: Libraries :: Application Frameworks"
-]
-readme = "README.md"
+description = "NemoCheck external plugin"
requires-python = ">=3.11,<3.14"
-license = "Apache-2.0"
-license-files = ["LICENSE"]
-
-maintainers = [
- {name = "julianstephen", email = "julian.stephen@gmail.com"}
-]
-
-authors = [
- {name = "julianstephen", email = "julian.stephen@gmail.com"}
-]
dependencies = [
"mcp>=1.16.0",
"mcp-contextforge-gateway",
]
-# URLs
-[project.urls]
-Homepage = "https://ibm.github.io/mcp-context-forge/"
-Documentation = "https://ibm.github.io/mcp-context-forge/"
-Repository = "https://github.com/IBM/mcp-context-forge"
-"Bug Tracker" = "https://github.com/IBM/mcp-context-forge/issues"
-Changelog = "https://github.com/IBM/mcp-context-forge/blob/main/CHANGELOG.md"
-
[tool.uv.sources]
mcp-contextforge-gateway = { git = "https://github.com/IBM/mcp-context-forge.git", rev = "main" }
-# ----------------------------------------------------------------
-# Optional dependency groups (extras)
-# ----------------------------------------------------------------
-[project.optional-dependencies]
-dev = [
- "black>=25.1.0",
- "pytest>=8.4.1",
- "pytest-asyncio>=1.1.0",
- "pytest-cov>=6.2.1",
- "pytest-dotenv>=0.5.2",
- "pytest-env>=1.1.5",
- "pytest-examples>=0.0.18",
- "pytest-md-report>=0.7.0",
- "pytest-rerunfailures>=15.1",
- "pytest-trio>=0.8.0",
- "pytest-xdist>=3.8.0",
- "ruff>=0.12.9",
- "unimport>=1.2.1",
- "uv>=0.8.11",
-]
-
# --------------------------------------------------------------------
# 🔧 setuptools-specific configuration
# --------------------------------------------------------------------
[tool.setuptools]
-include-package-data = true # ensure wheels include the data files
-
-# Automatic discovery: keep every package that starts with "nemocheck_external"
-[tool.setuptools.packages.find]
-include = ["nemocheck_external*"]
-exclude = ["tests*"]
-
-## Runtime data files ------------------------------------------------
-[tool.setuptools.package-data]
-nemocheck_external = [
- "resources/plugins/config.yaml",
-]
-
-[dependency-groups]
-dev = [
- "pytest>=8.4.2",
- "pytest-asyncio>=1.3.0",
- "pytest-cov>=7.0.0",
- "ruff>=0.14.14",
-]
+# No packages - this is just a runtime environment
+# Plugin code comes from the nemocheck package
+packages = []
From c6d83f2f81acebc8bf68f19697a3a1a1de68e6e9 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Fri, 13 Feb 2026 17:11:43 -0700
Subject: [PATCH 20/27] :art: Lint plugin
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
plugins/examples/nemocheck/plugin.py | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/plugins/examples/nemocheck/plugin.py b/plugins/examples/nemocheck/plugin.py
index 1751590..f7b58fd 100644
--- a/plugins/examples/nemocheck/plugin.py
+++ b/plugins/examples/nemocheck/plugin.py
@@ -62,7 +62,9 @@ def __init__(self, config: PluginConfig):
)
else:
self.check_endpoint = DEFAULT_CHECK_ENDPOINT
- logger.warning("Plugin config is empty or invalid, using default endpoint")
+ logger.warning(
+ "Plugin config is empty or invalid, using default endpoint"
+ )
logger.info(f"Nemo Check endpoint: {self.check_endpoint}")
async def prompt_pre_fetch(
@@ -119,7 +121,9 @@ async def tool_pre_invoke(
"type": "function",
"function": {
"name": tool_name,
- "arguments": payload.args.get("tool_args", None),
+ "arguments": payload.args.get(
+ "tool_args", None
+ ),
},
}
],
@@ -174,7 +178,9 @@ async def tool_pre_invoke(
code="checkserver_connection_error",
details={},
)
- return ToolPreInvokeResult(continue_processing=False, violation=violation)
+ return ToolPreInvokeResult(
+ continue_processing=False, violation=violation
+ )
async def tool_post_invoke(
self, payload: ToolPostInvokePayload, context: PluginContext
From b00c91cfa0f541897f98a5fddf6f055a6f1656c4 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Fri, 13 Feb 2026 17:24:27 -0700
Subject: [PATCH 21/27] :white_check_mark::fire: Remove old plugin with
refactor and update tests
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
.../examples/nemocheck/nemocheck/plugin.py | 256 ------------------
plugins/examples/nemocheck/plugin.py | 82 +++++-
.../nemocheck/tests/test_nemocheck.py | 18 +-
3 files changed, 89 insertions(+), 267 deletions(-)
delete mode 100644 plugins/examples/nemocheck/nemocheck/plugin.py
diff --git a/plugins/examples/nemocheck/nemocheck/plugin.py b/plugins/examples/nemocheck/nemocheck/plugin.py
deleted file mode 100644
index 4983795..0000000
--- a/plugins/examples/nemocheck/nemocheck/plugin.py
+++ /dev/null
@@ -1,256 +0,0 @@
-"""Adapter for Nemo-Check guardrails.
-
-Copyright 2025
-SPDX-License-Identifier: Apache-2.0
-Authors: julianstephen
-
-This module loads configurations for plugins.
-"""
-
-# First-Party
-from mcpgateway.plugins.framework import (
- Plugin,
- PluginConfig,
- PluginContext,
- PromptPosthookPayload,
- PromptPosthookResult,
- PromptPrehookPayload,
- PromptPrehookResult,
- ToolPostInvokePayload,
- ToolPostInvokeResult,
- ToolPreInvokePayload,
- ToolPreInvokeResult,
- PluginViolation,
-)
-
-
-import logging
-import os
-import requests
-
-headers = {
- "Content-Type": "application/json",
-}
-# Initialize logging service first
-logger = logging.getLogger(__name__)
-log_level = os.getenv("LOGLEVEL", "INFO").upper()
-logger.setLevel(log_level)
-
-MODEL_NAME = os.getenv(
- "NEMO_MODEL", "meta-llama/llama-3-3-70b-instruct"
-) # Currently only for logging.
-CHECK_ENDPOINT = os.getenv(
- "CHECK_ENDPOINT", "http://nemo-guardrails-service:8000"
-)
-
-
-class NemoCheck(Plugin):
- """Adapter for Nemo-Check guardrails."""
-
- def __init__(self, config: PluginConfig):
- """Entry init block for plugin.
-
- Args:
- logger: logger that the skill can make use of
- config: the skill configuration
- """
- super().__init__(config)
-
- async def prompt_pre_fetch(
- self, payload: PromptPrehookPayload, context: PluginContext
- ) -> PromptPrehookResult:
- """The plugin hook run before a prompt is retrieved and rendered.
-
- Args:
- payload: The prompt payload to be analyzed.
- context: contextual information about the hook call.
-
- Returns:
- The result of the plugin's analysis, including whether the prompt can proceed.
- """
-
- return PromptPrehookResult(continue_processing=True)
-
- async def prompt_post_fetch(
- self, payload: PromptPosthookPayload, context: PluginContext
- ) -> PromptPosthookResult:
- """Plugin hook run after a prompt is rendered.
-
- Args:
- payload: The prompt payload to be analyzed.
- context: Contextual information about the hook call.
-
- Returns:
- The result of the plugin's analysis, including whether the prompt can proceed.
- """
- return PromptPosthookResult(continue_processing=True)
-
- async def tool_pre_invoke(
- self, payload: ToolPreInvokePayload, context: PluginContext
- ) -> ToolPreInvokeResult:
- """Plugin hook run before a tool is invoked.
-
- Args:
- payload: The tool payload to be analyzed.
- context: Contextual information about the hook call.
-
- Returns:
- The result of the plugin's analysis, including whether the tool can proceed.
- """
- logger.info("[NemoCheck] Starting tool_pre_invoke")
- logger.info(payload)
- tool_name = payload.name # ("tool_name", None)
- check_nemo_payload = {
- "model": MODEL_NAME, # ideally optional
- "messages": [
- {
- "role": "assistant",
- "tool_calls": [
- {
- "id": "call_plug_adap_nem_check_123",
- "type": "function",
- "function": {
- "name": tool_name,
- "arguments": payload.args.get(
- "tool_args", None
- ),
- },
- }
- ],
- }
- ],
- }
- violation = None
- response = requests.post(
- CHECK_ENDPOINT, headers=headers, json=check_nemo_payload
- )
- if response.status_code == 200:
- data = response.json()
- status = data.get("status", "blocked")
- logger.debug(f"rails reply:{data}")
- if status == "success":
- metadata = data.get("rails_status")
- result = ToolPreInvokeResult(
- continue_processing=True, metadata=metadata
- )
- else:
- metadata = data.get("rails_status")
- violation = PluginViolation(
- reason=f"Tool Check status:{status}",
- description="Rails check blocked request",
- code=f"checkserver_http_status_code:{response.status_code}",
- details=metadata,
- )
- result = ToolPreInvokeResult(
- continue_processing=False,
- violation=violation,
- metadata=metadata,
- )
-
- else:
- violation = PluginViolation(
- reason="Tool Check Unavailable",
- description="Tool arguments check server returned error:",
- code=f"checkserver_http_status_code:{response.status_code}",
- details={},
- )
- result = ToolPreInvokeResult(
- continue_processing=False, violation=violation
- )
- logger.info(response)
-
- return result
-
- async def tool_post_invoke(
- self, payload: ToolPostInvokePayload, context: PluginContext
- ) -> ToolPostInvokeResult:
- """Plugin hook run after a tool is invoked.
-
- Args:
- payload: The tool result payload to be analyzed.
- context: Contextual information about the hook call.
-
- Returns:
- The result of the plugin's analysis, including whether the tool result should proceed.
- """
- logger.info(
- f"[NemoCheck] Starting tool post invoke hook with payload {payload}"
- )
-
- # Extract content from payload.result
- # payload.result format: {'content': [{'type': 'text', 'text': 'Hello, bob!'}]}
- result_content = payload.result.get("content", [])
- tool_name = payload.name
-
- if not result_content:
- logger.warning(
- "[NemoCheck] No content in tool result, skipping check"
- )
- return ToolPostInvokeResult(continue_processing=True)
-
- # Extract text content from the content array
- # TODO: what to do if there's actually multiple texts?
- text_content = ""
- for item in result_content:
- if item.get("type") == "text":
- text_content += item.get("text", "")
-
- # Build NeMo check payload for tool response
- check_nemo_payload = {
- "model": MODEL_NAME, # ideally optional
- "messages": [
- {"role": "tool", "content": text_content, "name": tool_name}
- ],
- }
-
- logger.debug(
- f"[NemoCheck] Payload for guardrail check: {check_nemo_payload}"
- )
-
- violation = None
- try:
- response = requests.post(
- CHECK_ENDPOINT, headers=headers, json=check_nemo_payload
- )
- if response.status_code == 200:
- data = response.json()
- status = data.get("status", "blocked")
- logger.debug(f"[NemoCheck] Rails reply: {data}")
-
- if status == "success":
- metadata = data.get("rails_status")
- result = ToolPostInvokeResult(
- continue_processing=True, metadata=metadata
- )
- else: # blocked
- metadata = data.get("rails_status")
- violation = PluginViolation(
- reason=f"Tool response check status: {status}",
- description="Rails check blocked tool response",
- code=f"checkserver_http_status_code:{response.status_code}",
- details=metadata,
- )
- result = ToolPostInvokeResult(
- continue_processing=False,
- violation=violation,
- metadata=metadata,
- )
- else:
- violation = PluginViolation(
- reason="Tool response check unavailable",
- description="Tool response check server returned error",
- code=f"checkserver_http_status_code:{response.status_code}",
- details={},
- )
- result = ToolPostInvokeResult(
- continue_processing=False, violation=violation
- )
-
- logger.info(f"[NemoCheck] Tool post invoke result: {result}")
- return result
-
- except Exception as e:
- logger.error(f"[NemoCheck] Error checking tool response: {e}")
- return ToolPostInvokeResult(
- continue_processing=True
- ) # Fail open on error
diff --git a/plugins/examples/nemocheck/plugin.py b/plugins/examples/nemocheck/plugin.py
index f7b58fd..8a6eaee 100644
--- a/plugins/examples/nemocheck/plugin.py
+++ b/plugins/examples/nemocheck/plugin.py
@@ -194,4 +194,84 @@ async def tool_post_invoke(
Returns:
The result of the plugin's analysis, including whether the tool result should proceed.
"""
- return ToolPostInvokeResult(continue_processing=True)
+ logger.info(
+ f"[NemoCheck] Starting tool post invoke hook with payload {payload}"
+ )
+
+ # Extract content from payload.result
+ # payload.result format: {'content': [{'type': 'text', 'text': 'Hello, bob!'}]}
+ result_content = payload.result.get("content", [])
+ tool_name = payload.name
+
+ if not result_content:
+ logger.warning(
+ "[NemoCheck] No content in tool result, skipping check"
+ )
+ return ToolPostInvokeResult(continue_processing=True)
+
+ # Extract text content from the content array
+ # TODO: what to do if there's actually multiple texts?
+ text_content = ""
+ for item in result_content:
+ if item.get("type") == "text":
+ text_content += item.get("text", "")
+
+ # Build NeMo check payload for tool response
+ check_nemo_payload = {
+ "model": MODEL_NAME, # ideally optional
+ "messages": [
+ {"role": "tool", "content": text_content, "name": tool_name}
+ ],
+ }
+
+ logger.debug(
+ f"[NemoCheck] Payload for guardrail check: {check_nemo_payload}"
+ )
+
+ violation = None
+ try:
+ response = requests.post(
+ self.check_endpoint, headers=HEADERS, json=check_nemo_payload
+ )
+ if response.status_code == 200:
+ data = response.json()
+ status = data.get("status", "blocked")
+ logger.debug(f"[NemoCheck] Rails reply: {data}")
+
+ if status == "success":
+ metadata = data.get("rails_status")
+ result = ToolPostInvokeResult(
+ continue_processing=True, metadata=metadata
+ )
+ else: # blocked
+ metadata = data.get("rails_status")
+ violation = PluginViolation(
+ reason=f"Tool response check status: {status}",
+ description="Rails check blocked tool response",
+ code=f"checkserver_http_status_code:{response.status_code}",
+ details=metadata,
+ )
+ result = ToolPostInvokeResult(
+ continue_processing=False,
+ violation=violation,
+ metadata=metadata,
+ )
+ else:
+ violation = PluginViolation(
+ reason="Tool response check unavailable",
+ description="Tool response check server returned error",
+ code=f"checkserver_http_status_code:{response.status_code}",
+ details={},
+ )
+ result = ToolPostInvokeResult(
+ continue_processing=False, violation=violation
+ )
+
+ logger.info(f"[NemoCheck] Tool post invoke result: {result}")
+ return result
+
+ except Exception as e:
+ logger.error(f"[NemoCheck] Error checking tool response: {e}")
+ return ToolPostInvokeResult(
+ continue_processing=True
+ ) # Fail open on error
diff --git a/plugins/examples/nemocheck/tests/test_nemocheck.py b/plugins/examples/nemocheck/tests/test_nemocheck.py
index 10a7e7f..9fe24c1 100644
--- a/plugins/examples/nemocheck/tests/test_nemocheck.py
+++ b/plugins/examples/nemocheck/tests/test_nemocheck.py
@@ -9,6 +9,7 @@
# First-Party
from mcpgateway.plugins.framework import (
PluginConfig,
+ PluginContext,
GlobalContext,
PromptPrehookPayload,
ToolPostInvokePayload,
@@ -33,8 +34,8 @@ def plugin():
@pytest.fixture
def context():
- """Create a GlobalContext instance."""
- return GlobalContext(request_id="1")
+ """Create a PluginContext instance."""
+ return PluginContext(global_context=GlobalContext(request_id="1"))
def mock_http_response(status_code, response_data=None):
@@ -52,7 +53,6 @@ async def test_prompt_pre_fetch(plugin, context):
payload = PromptPrehookPayload(
prompt_id="test_prompt", args={"arg0": "This is an argument"}
)
- context = GlobalContext(request_id="1")
result = await plugin.prompt_pre_fetch(payload, context)
assert result.continue_processing
@@ -99,7 +99,7 @@ async def test_tool_pre_invoke_scenarios(
)
with patch(
- "nemocheck.plugin.requests.post",
+ "plugin.requests.post",
return_value=mock_http_response(status_code, response_data),
):
result = await plugin.tool_pre_invoke(payload, context)
@@ -150,7 +150,7 @@ async def test_tool_post_invoke_http_scenarios(
)
with patch(
- "nemocheck.plugin.requests.post",
+ "plugin.requests.post",
return_value=mock_http_response(status_code, response_data),
):
result = await plugin.tool_post_invoke(payload, context)
@@ -191,7 +191,7 @@ async def test_tool_post_invoke_concatenates_text(plugin, context):
)
with patch(
- "nemocheck.plugin.requests.post",
+ "plugin.requests.post",
return_value=mock_http_response(
200, {"status": "success", "rails_status": {}}
),
@@ -217,7 +217,7 @@ async def test_tool_post_invoke_filters_non_text(plugin, context):
)
with patch(
- "nemocheck.plugin.requests.post",
+ "plugin.requests.post",
return_value=mock_http_response(
200, {"status": "success", "rails_status": {}}
),
@@ -237,9 +237,7 @@ async def test_tool_post_invoke_fails_open_on_exception(plugin, context):
result={"content": [{"type": "text", "text": "content"}]},
)
- with patch(
- "nemocheck.plugin.requests.post", side_effect=Exception("Network error")
- ):
+ with patch("plugin.requests.post", side_effect=Exception("Network error")):
result = await plugin.tool_post_invoke(payload, context)
assert result.continue_processing
From 1354e0e6a576d624c283554607306c095eb492b8 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Fri, 13 Feb 2026 17:29:45 -0700
Subject: [PATCH 22/27] :loud_sound: Update plugin log
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
plugins/examples/nemocheck/plugin.py | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/plugins/examples/nemocheck/plugin.py b/plugins/examples/nemocheck/plugin.py
index 8a6eaee..cc87980 100644
--- a/plugins/examples/nemocheck/plugin.py
+++ b/plugins/examples/nemocheck/plugin.py
@@ -107,9 +107,11 @@ async def tool_pre_invoke(
Returns:
The result of the plugin's analysis, including whether the tool can proceed.
"""
- logger.info("tool_pre_invoke....")
- logger.info(payload)
- tool_name = payload.name # ("tool_name", None)
+ logger.info(
+ f"[NemoCheck] Starting tool pre invoke hook with payload {payload}"
+ )
+
+ tool_name = payload.name
check_nemo_payload = {
"model": MODEL_NAME,
"messages": [
@@ -139,7 +141,7 @@ async def tool_pre_invoke(
if response.status_code == 200:
data = response.json()
status = data.get("status", "blocked")
- logger.debug(f"rails reply: {data}")
+ logger.debug(f"[NemoCheck] Rails reply: {data}")
if status == "success":
metadata = data.get("rails_status")
@@ -171,7 +173,7 @@ async def tool_pre_invoke(
)
except Exception as e:
- logger.error(f"Error calling Nemo Check endpoint: {e}")
+ logger.error(f"[NemoCheck] Error checking tool arguments: {e}")
violation = PluginViolation(
reason="Tool Check Error",
description=f"Failed to connect to check server: {str(e)}",
From 0f710aa1fbf2f790f7bb95b3d85856429f4b94f7 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Mon, 16 Feb 2026 10:02:06 -0700
Subject: [PATCH 23/27] :white_check_mark: Update server tests
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
tests/test_server.py | 119 ++++++++++++++++++++++---------------------
1 file changed, 61 insertions(+), 58 deletions(-)
diff --git a/tests/test_server.py b/tests/test_server.py
index efc0fe7..64dcfe6 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -206,10 +206,25 @@ async def test_getToolPostInvokeResponse_modified_payload(
# Inject mock manager
src.server.manager = mock_manager
- # Call the function
- response = await src.server.getToolPostInvokeResponse(
- sample_tool_result_body
- )
+ # Spy on json.dumps to capture what body is being serialized
+ original_dumps = json.dumps
+ captured_body = None
+
+ def spy_dumps(obj, **kwargs):
+ nonlocal captured_body
+ # Capture the body dict that's being serialized
+ if isinstance(obj, dict) and "result" in obj and "jsonrpc" in obj:
+ captured_body = obj
+ return original_dumps(obj, **kwargs)
+
+ json.dumps = spy_dumps
+ try:
+ # Call the function
+ response = await src.server.getToolPostInvokeResponse(
+ sample_tool_result_body
+ )
+ finally:
+ json.dumps = original_dumps
# Verify the hook was called
assert mock_manager.invoke_hook.called
@@ -217,6 +232,18 @@ async def test_getToolPostInvokeResponse_modified_payload(
# Verify response was created
assert response is not None
+ # Verify the body was modified with the new result
+ assert captured_body is not None, (
+ "json.dumps should have been called with the modified body"
+ )
+ assert captured_body["result"] == modified_result
+ assert (
+ captured_body["result"]["content"][0]["text"] == "Modified tool result"
+ )
+ # Verify original metadata (jsonrpc, id) is preserved
+ assert captured_body["jsonrpc"] == sample_tool_result_body["jsonrpc"]
+ assert captured_body["id"] == sample_tool_result_body["id"]
+
@pytest.mark.asyncio
async def test_getToolPostInvokeResponse_multiple_content_items(
@@ -318,49 +345,6 @@ async def test_process_response_body_buffer_with_sse_format(
assert response is not None
-@pytest.mark.asyncio
-async def test_process_response_body_buffer_empty(
- mock_envoy_modules, mock_manager
-):
- """Test process_response_body_buffer with empty buffer."""
- setup_response_mocks(mock_envoy_modules)
- import src.server
-
- src.server.manager = mock_manager
- response = await src.server.process_response_body_buffer(bytearray())
-
- # Verify ProcessingResponse was returned
- assert response is not None
- assert not mock_manager.invoke_hook.called, (
- "Tool post-invoke hook should not be called for empty buffer"
- )
-
-
-@pytest.mark.asyncio
-async def test_process_response_body_buffer_non_tool_result(
- mock_envoy_modules, mock_manager
-):
- """Test process_response_body_buffer with non-tool result (error response)."""
- setup_response_mocks(mock_envoy_modules)
- import src.server
-
- src.server.manager = mock_manager
-
- error_response = {
- "jsonrpc": "2.0",
- "id": "test-error",
- "error": {"code": -32000, "message": "Error"},
- }
- buffer = bytearray(json.dumps(error_response).encode("utf-8"))
- response = await src.server.process_response_body_buffer(buffer)
-
- # Verify ProcessingResponse was returned
- assert response is not None
- assert not mock_manager.invoke_hook.called, (
- "Tool post-invoke hook should not be called for error responses"
- )
-
-
@pytest.mark.asyncio
async def test_process_response_body_buffer_multiple_chunks_scenario(
mock_envoy_modules, mock_manager
@@ -398,26 +382,45 @@ async def test_process_response_body_buffer_multiple_chunks_scenario(
@pytest.mark.asyncio
-async def test_process_response_body_buffer_single_chunk_with_end_of_stream(
+async def test_process_response_body_buffer_empty(
mock_envoy_modules, mock_manager
):
- """Test buffering: all content in one chunk with end_of_stream."""
+ """Test process_response_body_buffer with empty buffer."""
setup_response_mocks(mock_envoy_modules)
import src.server
- setup_manager_with_result(mock_manager)
src.server.manager = mock_manager
+ response = await src.server.process_response_body_buffer(bytearray())
- tool_result = {
+ # Verify hook is NOT called for empty buffer
+ assert not mock_manager.invoke_hook.called, (
+ "Tool post-invoke hook should not be called for empty buffer"
+ )
+ # Verify response is returned (function doesn't crash on empty buffer)
+ assert response is not None
+
+
+@pytest.mark.asyncio
+async def test_process_response_body_buffer_non_tool_result(
+ mock_envoy_modules, mock_manager
+):
+ """Test process_response_body_buffer with non-tool result (error response)."""
+ setup_response_mocks(mock_envoy_modules)
+ import src.server
+
+ src.server.manager = mock_manager
+
+ error_response = {
"jsonrpc": "2.0",
- "id": "test-single",
- "result": {"content": [{"type": "text", "text": "Single chunk"}]},
+ "id": "test-error",
+ "error": {"code": -32000, "message": "Error"},
}
- buffer = bytearray(json.dumps(tool_result).encode("utf-8"))
+ buffer = bytearray(json.dumps(error_response).encode("utf-8"))
response = await src.server.process_response_body_buffer(buffer)
- assert mock_manager.invoke_hook.called
- payload = mock_manager.invoke_hook.call_args[0][1]
- verify_payload_content(payload, tool_result["result"], "Single chunk")
- # Verify ProcessingResponse was returned
+ # Verify hook is NOT called for error responses
+ assert not mock_manager.invoke_hook.called, (
+ "Tool post-invoke hook should not be called for error responses"
+ )
+ # Verify response is returned (function handles error responses gracefully)
assert response is not None
From 5eaa94c3abc01fd373a09d722963e3224faafa70 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Mon, 16 Feb 2026 10:36:33 -0700
Subject: [PATCH 24/27] :goal_net: Update immediate response for not continued
processing
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
src/server.py | 65 ++++++++++++++++++++++++++------------------
tests/test_server.py | 42 ++++++++++++++++++++++++----
2 files changed, 76 insertions(+), 31 deletions(-)
diff --git a/src/server.py b/src/server.py
index 2df83be..4848ea7 100644
--- a/src/server.py
+++ b/src/server.py
@@ -131,6 +131,10 @@ async def getToolPostInvokeResponse(body):
Invokes plugins after a tool has been called, allowing for result validation,
modification, or filtering of the tool output.
+
+ Note: In STREAMED mode, blocking responses may fail if headers are already sent.
+ This implementation uses immediate_response to attempt early termination, but
+ it may not always succeed due to streaming constraints.
"""
# FIXME: size of content array is expected to be 1
# for content in body["result"]["content"]:
@@ -145,37 +149,45 @@ async def getToolPostInvokeResponse(body):
)
logger.debug(f"**** Tool Post Invoke result {result}")
if not result.continue_processing:
- # Build error response to replace the tool result
+ # Build error message
+ error_message = "Tool response forbidden"
+ if result.violation is not None:
+ violation: PluginViolation = result.violation
+ error_message = f"{violation.reason} -- {violation.description}"
+
error_body = {
"jsonrpc": body["jsonrpc"],
"id": body["id"],
- "error": {"code": -32000, "message": "Tool response forbidden"},
+ "error": {"code": -32000, "message": error_message},
}
+
+ # In STREAMED mode, we attempt to use immediate_response to terminate early
+ # This may fail if response headers have already been sent
+ logger.warning(
+ "Tool post-invoke blocking in STREAMED mode - may be unreliable. "
+ "Consider using BUFFERED mode or moving validation to pre-invoke hooks."
+ )
body_resp = ep.ProcessingResponse(
- response_body=ep.BodyResponse(
- response=ep.CommonResponse(
- header_mutation=ep.HeaderMutation(
- set_headers=[
- core.HeaderValueOption(
- header=core.HeaderValue(
- key="content-type",
- raw_value="application/json".encode(
- "utf-8"
- ),
- )
- ),
- core.HeaderValueOption(
- header=core.HeaderValue(
- key="x-mcp-denied",
- raw_value="True".encode("utf-8"),
- )
- ),
- ],
- ),
- body_mutation=ep.BodyMutation(
- body=(json.dumps(error_body)).encode("utf-8")
- ),
- )
+ immediate_response=ep.ImmediateResponse(
+ # Use 200 status with error in body for MCP protocol compatibility
+ status=http_status_pb2.HttpStatus(code=200),
+ headers=ep.HeaderMutation(
+ set_headers=[
+ core.HeaderValueOption(
+ header=core.HeaderValue(
+ key="content-type",
+ raw_value="application/json".encode("utf-8"),
+ )
+ ),
+ core.HeaderValueOption(
+ header=core.HeaderValue(
+ key="x-mcp-denied",
+ raw_value="True".encode("utf-8"),
+ )
+ ),
+ ],
+ ),
+ body=(json.dumps(error_body)).encode("utf-8"),
)
)
else:
@@ -192,6 +204,7 @@ async def getToolPostInvokeResponse(body):
else:
body_mutation = ep.BodyResponse(response=ep.CommonResponse())
body_resp = ep.ProcessingResponse(response_body=body_mutation)
+ logger.info(f"****Tool Post Invoke Return body: {body_resp}****")
return body_resp
diff --git a/tests/test_server.py b/tests/test_server.py
index 64dcfe6..ab425bb 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -147,7 +147,14 @@ async def test_getToolPostInvokeResponse_continue_processing(
async def test_getToolPostInvokeResponse_blocked(
mock_envoy_modules, mock_manager, sample_tool_result_body
):
- """Test getToolPostInvokeResponse when plugin blocks the response."""
+ """Test getToolPostInvokeResponse when plugin blocks the response.
+
+ This test verifies that when continue_processing=False, the function
+ uses immediate_response (not response_body) and includes violation details.
+ """
+ # Setup mocks for immediate_response path
+ setup_response_mocks(mock_envoy_modules)
+
# Import server after mocking
import src.server
@@ -166,10 +173,23 @@ async def test_getToolPostInvokeResponse_blocked(
# Inject mock manager
src.server.manager = mock_manager
- # Call the function
- response = await src.server.getToolPostInvokeResponse(
- sample_tool_result_body
- )
+ # Capture json.dumps calls to verify error body content
+ original_dumps = json.dumps
+ captured_bodies = []
+
+ def spy_dumps(obj, **kwargs):
+ if isinstance(obj, dict) and "error" in obj:
+ captured_bodies.append(obj)
+ return original_dumps(obj, **kwargs)
+
+ json.dumps = spy_dumps
+ try:
+ # Call the function
+ response = await src.server.getToolPostInvokeResponse(
+ sample_tool_result_body
+ )
+ finally:
+ json.dumps = original_dumps
# Verify the hook was called with correct payload
assert mock_manager.invoke_hook.called
@@ -181,6 +201,18 @@ async def test_getToolPostInvokeResponse_blocked(
# Verify response was created (error path taken)
assert response is not None
+ # Verify error body was created with violation details
+ assert len(captured_bodies) > 0
+ error_body = captured_bodies[0]
+ assert "error" in error_body
+ assert error_body["error"]["code"] == -32000
+ # Verify violation message is included
+ assert "Sensitive content detected" in error_body["error"]["message"]
+ assert (
+ "Tool response contains forbidden content"
+ in error_body["error"]["message"]
+ )
+
@pytest.mark.asyncio
async def test_getToolPostInvokeResponse_modified_payload(
From 3fc2672a02c6602857c108aeb05dc725fd1729e5 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Mon, 16 Feb 2026 10:41:55 -0700
Subject: [PATCH 25/27] :recycle: Refactor common immediate response
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
src/server.py | 139 ++++++++++++++++++++++----------------------------
1 file changed, 62 insertions(+), 77 deletions(-)
diff --git a/src/server.py b/src/server.py
index 4848ea7..7db6b70 100644
--- a/src/server.py
+++ b/src/server.py
@@ -18,7 +18,6 @@
PromptPrehookPayload,
ToolPostInvokePayload,
ToolPreInvokePayload,
- PluginViolation,
)
from mcpgateway.plugins.framework import PluginManager
@@ -44,6 +43,56 @@ def set_result_in_body(body, result_args):
body["params"]["arguments"] = result_args
+def create_mcp_immediate_error_response(body, error_message, violation=None):
+ """
+ Create an MCP error response using immediate_response.
+
+ This helper creates a standardized error response that can be used
+ for both pre-invoke and post-invoke blocking scenarios.
+
+ Args:
+ body: The original request/response body containing jsonrpc and id
+ error_message: Base error message
+ violation: Optional PluginViolation with reason and description
+
+ Returns:
+ ProcessingResponse with immediate_response containing the error
+ """
+ # Build error message with violation details if present
+ if violation is not None:
+ error_message = f"{violation.reason} -- {violation.description}"
+
+ error_body = {
+ "jsonrpc": body["jsonrpc"],
+ "id": body["id"],
+ "error": {"code": -32000, "message": error_message},
+ }
+
+ return ep.ProcessingResponse(
+ immediate_response=ep.ImmediateResponse(
+ # Use 200 status with error in body for MCP protocol compatibility
+ status=http_status_pb2.HttpStatus(code=200),
+ headers=ep.HeaderMutation(
+ set_headers=[
+ core.HeaderValueOption(
+ header=core.HeaderValue(
+ key="content-type",
+ raw_value="application/json".encode("utf-8"),
+ )
+ ),
+ core.HeaderValueOption(
+ header=core.HeaderValue(
+ key="x-mcp-denied",
+ raw_value="True".encode("utf-8"),
+ )
+ ),
+ ],
+ ),
+ body=(json.dumps(error_body)).encode("utf-8"),
+ )
+ )
+
+
# ============================================================================
# MCP HOOK HANDLERS
# ============================================================================
@@ -73,37 +122,10 @@ async def getToolPreInvokeResponse(body):
)
logger.debug(f"**** Tool Pre Invoke Result: {result} ****")
if not result.continue_processing:
- error_message = "No go - Tool args forbidden"
- if result.violation is not None:
- violation: PluginViolation = result.violation
- error_message = f"{violation.reason} -- {violation.description}"
- error_body = {
- "jsonrpc": body["jsonrpc"],
- "id": body["id"],
- "error": {"code": -32000, "message": error_message},
- }
- body_resp = ep.ProcessingResponse(
- immediate_response=ep.ImmediateResponse(
- # ok for stream, with error in body
- status=http_status_pb2.HttpStatus(code=200),
- headers=ep.HeaderMutation(
- set_headers=[
- core.HeaderValueOption(
- header=core.HeaderValue(
- key="content-type",
- raw_value="application/json".encode("utf-8"),
- )
- ),
- core.HeaderValueOption(
- header=core.HeaderValue(
- key="x-mcp-denied",
- raw_value="True".encode("utf-8"),
- )
- ),
- ],
- ),
- body=(json.dumps(error_body)).encode("utf-8"),
- )
+ body_resp = create_mcp_immediate_error_response(
+ body,
+ error_message="No go - Tool args forbidden",
+ violation=result.violation,
)
else:
logger.debug("continue_processing true")
@@ -149,46 +171,12 @@ async def getToolPostInvokeResponse(body):
)
logger.debug(f"**** Tool Post Invoke result {result}")
if not result.continue_processing:
- # Build error message
- error_message = "Tool response forbidden"
- if result.violation is not None:
- violation: PluginViolation = result.violation
- error_message = f"{violation.reason} -- {violation.description}"
-
- error_body = {
- "jsonrpc": body["jsonrpc"],
- "id": body["id"],
- "error": {"code": -32000, "message": error_message},
- }
-
# In STREAMED mode, we attempt to use immediate_response to terminate early
# This may fail if response headers have already been sent
- logger.warning(
- "Tool post-invoke blocking in STREAMED mode - may be unreliable. "
- "Consider using BUFFERED mode or moving validation to pre-invoke hooks."
- )
- body_resp = ep.ProcessingResponse(
- immediate_response=ep.ImmediateResponse(
- # Use 200 status with error in body for MCP protocol compatibility
- status=http_status_pb2.HttpStatus(code=200),
- headers=ep.HeaderMutation(
- set_headers=[
- core.HeaderValueOption(
- header=core.HeaderValue(
- key="content-type",
- raw_value="application/json".encode("utf-8"),
- )
- ),
- core.HeaderValueOption(
- header=core.HeaderValue(
- key="x-mcp-denied",
- raw_value="True".encode("utf-8"),
- )
- ),
- ],
- ),
- body=(json.dumps(error_body)).encode("utf-8"),
- )
+ body_resp = create_mcp_immediate_error_response(
+ body,
+ error_message="Tool response forbidden",
+ violation=result.violation,
)
else:
result_payload = result.modified_payload
@@ -225,13 +213,10 @@ async def getPromptPreFetchResponse(body):
)
logger.info(result)
if not result.continue_processing:
- body_resp = ep.ProcessingResponse(
- immediate_response=ep.ImmediateResponse(
- status=http_status_pb2.HttpStatus(
- code=http_status_pb2.Forbidden
- ),
- details="No go",
- )
+ body_resp = create_mcp_immediate_error_response(
+ body,
+ error_message="Tool response forbidden",
+ violation=result.violation,
)
else:
body["params"]["arguments"] = result.modified_payload.args
From b9bf557a478b435a8f68bbd10c028853f73db179 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Fri, 20 Feb 2026 11:20:50 -0700
Subject: [PATCH 26/27] :recycle::goal_net: Consistent error responses
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
plugins/examples/nemocheck/plugin.py | 36 ++++++-----
.../nemocheck/tests/test_nemocheck.py | 60 ++++++++++++++-----
2 files changed, 66 insertions(+), 30 deletions(-)
diff --git a/plugins/examples/nemocheck/plugin.py b/plugins/examples/nemocheck/plugin.py
index cc87980..8d1f704 100644
--- a/plugins/examples/nemocheck/plugin.py
+++ b/plugins/examples/nemocheck/plugin.py
@@ -153,7 +153,7 @@ async def tool_pre_invoke(
violation = PluginViolation(
reason=f"Check tool rails:{status}.",
description=json.dumps(data),
- code=f"checkserver_http_status_code:{response.status_code}",
+ code="NEMO_RAILS_BLOCKED",
details=metadata,
)
return ToolPreInvokeResult(
@@ -164,9 +164,9 @@ async def tool_pre_invoke(
else:
violation = PluginViolation(
reason="Tool Check Unavailable",
- description="Tool arguments check server returned error",
- code=f"checkserver_http_status_code:{response.status_code}",
- details={},
+ description=f"Tool arguments check server returned error. Status code: {response.status_code}, Response: {response.text}",
+ code="NEMO_SERVER_ERROR",
+ details={"status_code": response.status_code},
)
return ToolPreInvokeResult(
continue_processing=False, violation=violation
@@ -177,8 +177,8 @@ async def tool_pre_invoke(
violation = PluginViolation(
reason="Tool Check Error",
description=f"Failed to connect to check server: {str(e)}",
- code="checkserver_connection_error",
- details={},
+ code="NEMO_CONNECTION_ERROR",
+ details={"error": str(e)},
)
return ToolPreInvokeResult(
continue_processing=False, violation=violation
@@ -248,9 +248,9 @@ async def tool_post_invoke(
else: # blocked
metadata = data.get("rails_status")
violation = PluginViolation(
- reason=f"Tool response check status: {status}",
- description="Rails check blocked tool response",
- code=f"checkserver_http_status_code:{response.status_code}",
+ reason=f"Check tool rails:{status}.",
+ description=json.dumps(data),
+ code="NEMO_RAILS_BLOCKED",
details=metadata,
)
result = ToolPostInvokeResult(
@@ -260,10 +260,10 @@ async def tool_post_invoke(
)
else:
violation = PluginViolation(
- reason="Tool response check unavailable",
- description="Tool response check server returned error",
- code=f"checkserver_http_status_code:{response.status_code}",
- details={},
+ reason="Tool Check Unavailable",
+ description=f"Tool response check server returned error. Status code: {response.status_code}, Response: {response.text}",
+ code="NEMO_SERVER_ERROR",
+ details={"status_code": response.status_code},
)
result = ToolPostInvokeResult(
continue_processing=False, violation=violation
@@ -274,6 +274,12 @@ async def tool_post_invoke(
except Exception as e:
logger.error(f"[NemoCheck] Error checking tool response: {e}")
+ violation = PluginViolation(
+ reason="Tool Check Error",
+ description=f"Failed to connect to check server: {str(e)}",
+ code="NEMO_CONNECTION_ERROR",
+ details={"error": str(e)},
+ )
return ToolPostInvokeResult(
- continue_processing=True
- ) # Fail open on error
+ continue_processing=False, violation=violation
+ )
diff --git a/plugins/examples/nemocheck/tests/test_nemocheck.py b/plugins/examples/nemocheck/tests/test_nemocheck.py
index 9fe24c1..7c1cbd7 100644
--- a/plugins/examples/nemocheck/tests/test_nemocheck.py
+++ b/plugins/examples/nemocheck/tests/test_nemocheck.py
@@ -59,7 +59,7 @@ async def test_prompt_pre_fetch(plugin, context):
@pytest.mark.asyncio
@pytest.mark.parametrize(
- "status_code,response_data,expected_continue,has_violation",
+ "status_code,response_data,expected_continue,has_violation,expected_code",
[
(
200,
@@ -71,6 +71,7 @@ async def test_prompt_pre_fetch(plugin, context):
},
True,
False,
+ None,
),
(
200,
@@ -80,8 +81,9 @@ async def test_prompt_pre_fetch(plugin, context):
},
False,
True,
+ "NEMO_RAILS_BLOCKED",
),
- (503, None, False, True),
+ (503, None, False, True, "NEMO_SERVER_ERROR"),
],
)
async def test_tool_pre_invoke_scenarios(
@@ -91,8 +93,9 @@ async def test_tool_pre_invoke_scenarios(
response_data,
expected_continue,
has_violation,
+ expected_code,
):
- """Test tool_pre_invoke with various scenarios."""
+ """Test tool_pre_invoke with various scenarios including error codes."""
payload = ToolPreInvokePayload(
name="test_tool",
args={"tool_args": '{"param": "value"}'},
@@ -106,11 +109,13 @@ async def test_tool_pre_invoke_scenarios(
assert result.continue_processing == expected_continue
assert (result.violation is not None) == has_violation
+ if has_violation:
+ assert result.violation.code == expected_code
@pytest.mark.asyncio
@pytest.mark.parametrize(
- "status_code,response_data,expected_continue,has_violation",
+ "status_code,response_data,expected_continue,has_violation,expected_code",
[
(
200,
@@ -122,6 +127,7 @@ async def test_tool_pre_invoke_scenarios(
},
True,
False,
+ None,
),
(
200,
@@ -131,8 +137,9 @@ async def test_tool_pre_invoke_scenarios(
},
False,
True,
+ "NEMO_RAILS_BLOCKED",
),
- (500, None, False, True),
+ (500, None, False, True, "NEMO_SERVER_ERROR"),
],
)
async def test_tool_post_invoke_http_scenarios(
@@ -142,8 +149,9 @@ async def test_tool_post_invoke_http_scenarios(
response_data,
expected_continue,
has_violation,
+ expected_code,
):
- """Test tool_post_invoke with various HTTP response scenarios."""
+ """Test tool_post_invoke with various HTTP response scenarios including error codes."""
payload = ToolPostInvokePayload(
name="test_tool",
result={"content": [{"type": "text", "text": "Test content"}]},
@@ -157,6 +165,8 @@ async def test_tool_post_invoke_http_scenarios(
assert result.continue_processing == expected_continue
assert (result.violation is not None) == has_violation
+ if has_violation:
+ assert result.violation.code == expected_code
@pytest.mark.asyncio
@@ -230,15 +240,35 @@ async def test_tool_post_invoke_filters_non_text(plugin, context):
@pytest.mark.asyncio
-async def test_tool_post_invoke_fails_open_on_exception(plugin, context):
- """Test tool_post_invoke fails open on exceptions."""
- payload = ToolPostInvokePayload(
- name="test_tool",
- result={"content": [{"type": "text", "text": "content"}]},
- )
+@pytest.mark.parametrize(
+ "hook_name,payload_factory",
+ [
+ (
+ "tool_pre_invoke",
+ lambda: ToolPreInvokePayload(
+ name="test_tool", args={"tool_args": '{"param": "value"}'}
+ ),
+ ),
+ (
+ "tool_post_invoke",
+ lambda: ToolPostInvokePayload(
+ name="test_tool",
+ result={"content": [{"type": "text", "text": "content"}]},
+ ),
+ ),
+ ],
+)
+async def test_connection_error_handling(
+ plugin, context, hook_name, payload_factory
+):
+ """Test both hooks fail closed on connection errors with NEMO_CONNECTION_ERROR code."""
+ payload = payload_factory()
+ hook = getattr(plugin, hook_name)
with patch("plugin.requests.post", side_effect=Exception("Network error")):
- result = await plugin.tool_post_invoke(payload, context)
+ result = await hook(payload, context)
- assert result.continue_processing
- assert result.violation is None
+ assert not result.continue_processing
+ assert result.violation is not None
+ assert result.violation.code == "NEMO_CONNECTION_ERROR"
+ assert "Network error" in result.violation.description
From 946237c511348e11c25e95bc571939cd7a2c2934 Mon Sep 17 00:00:00 2001
From: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
Date: Mon, 23 Feb 2026 12:06:22 -0700
Subject: [PATCH 27/27] :recycle: Refactor post tool invoke processing
Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
---
src/server.py | 27 +++++++++++++++------------
1 file changed, 15 insertions(+), 12 deletions(-)
diff --git a/src/server.py b/src/server.py
index 7db6b70..e7785e5 100644
--- a/src/server.py
+++ b/src/server.py
@@ -178,20 +178,23 @@ async def getToolPostInvokeResponse(body):
error_message="Tool response forbidden",
violation=result.violation,
)
- else:
- result_payload = result.modified_payload
- if result_payload is not None:
- body["result"] = result_payload.result
- body_mutation = ep.BodyResponse(
- response=ep.CommonResponse(
- body_mutation=ep.BodyMutation(
- body=json.dumps(body).encode("utf-8")
- )
+ logger.info(f"****Tool Post Invoke Return body: {body_resp}****")
+ return body_resp
+
+ # Continue processing - allow or modify the response
+ result_payload = result.modified_payload
+ if result_payload is not None:
+ body["result"] = result_payload.result
+ body_mutation = ep.BodyResponse(
+ response=ep.CommonResponse(
+ body_mutation=ep.BodyMutation(
+ body=json.dumps(body).encode("utf-8")
)
)
- else:
- body_mutation = ep.BodyResponse(response=ep.CommonResponse())
- body_resp = ep.ProcessingResponse(response_body=body_mutation)
+ )
+ else:
+ body_mutation = ep.BodyResponse(response=ep.CommonResponse())
+ body_resp = ep.ProcessingResponse(response_body=body_mutation)
logger.info(f"****Tool Post Invoke Return body: {body_resp}****")
return body_resp