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-127Updated 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-127Updated 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-127Updated 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-127Updated 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