diff --git a/Dockerfile b/Dockerfile index e8fd925..151456e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,10 +20,11 @@ RUN mkdir src COPY src/ ./src/ COPY resources ./resources/ +COPY plugins ./plugins/ -WORKDIR /app/src +#WORKDIR /app/src # Expose the gRPC port EXPOSE 50052 # Run the server -CMD ["python", "server.py"] +CMD ["python", "src/server.py"] diff --git a/Makefile b/Makefile index df17445..b7c5a1f 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ build: load: kind load docker-image $(IMAGE_LOCAL) --name mcp-gateway -podname := $(shell kubectl get pods -A |grep my-extproc | grep -v Terminating | awk '{print $$2}') +podname := $(shell kubectl get pods -A |grep ${IMAGE_BASE} | grep -v Terminating | awk '{print $$2}') log: kubectl logs ${podname} -n istio-system -f diff --git a/ext-proc.yaml b/ext-proc.yaml index 5a0cfd7..a355efa 100644 --- a/ext-proc.yaml +++ b/ext-proc.yaml @@ -30,9 +30,9 @@ spec: containers: - name: plugins-adapter image: plugins-adapter:0.1.0 - # command: ["bash", "-c","--"] - # args: ["while true; do sleep 3600; done"] -# imagePullPolicy: IfNotPresent + #command: ["bash", "-c","--"] + #args: ["while true; do sleep 3600; done"] + #imagePullPolicy: IfNotPresent env: - name: PLUGINS_SERVER_HOST value: "0.0.0.0" @@ -44,5 +44,7 @@ spec: value: "./resources/config/config.yaml" - name: PLUGIN_MANAGER_CONFIG value: "./resources/config/config.yaml" + - name: PYTHONPATH + value: "./" ports: - containerPort: 50052 diff --git a/plugins/examples/context_forge/internal/regex_filter/README.md b/plugins/examples/context_forge/internal/regex_filter/README.md new file mode 100644 index 0000000..550dc76 --- /dev/null +++ b/plugins/examples/context_forge/internal/regex_filter/README.md @@ -0,0 +1,244 @@ +# Search Replace Plugin for MCP Gateway + +> Author: Teryl Taylor +> Version: 0.1.0 + +A native plugin for MCP Gateway that performs regex-based search and replace operations on prompt arguments and responses. + +## Features + +- **Pre-fetch Hook**: Modifies prompt arguments before prompt retrieval +- **Post-fetch Hook**: Modifies rendered prompt messages after processing +- **Regex Support**: Full regex pattern matching and replacement +- **Multiple Patterns**: Configure multiple search/replace pairs +- **Chain Transformations**: Apply replacements in sequence + +## Installation + +The plugin is included with MCP Gateway and requires no additional installation. Simply enable it in your configuration. + +## Configuration + +Add the plugin to your `plugins/config.yaml`: + +```yaml +plugins: + - name: "SearchReplacePlugin" + kind: "plugins.regex_filter.search_replace.SearchReplacePlugin" + description: "Performs text transformations using regex patterns" + version: "0.1" + author: "ContextForge" + hooks: ["prompt_pre_fetch", "prompt_post_fetch"] + tags: ["transformer", "regex", "text-processing"] + mode: "enforce" # enforce | permissive | disabled + priority: 150 # Lower = higher priority + conditions: + - prompts: ["test_prompt", "chat_prompt"] # Apply to specific prompts + server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + config: + words: + - search: "crap" + replace: "crud" + - search: "damn" + replace: "darn" + - search: "\\bAI\\b" # Word boundary regex + replace: "artificial intelligence" +``` + +## Configuration Options + +### Plugin Settings + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Unique plugin identifier | +| `kind` | string | Yes | Plugin class path | +| `hooks` | array | Yes | Hook points to enable | +| `mode` | string | No | Execution mode: `enforce`, `permissive`, or `disabled` | +| `priority` | integer | No | Execution order (default: 150) | +| `conditions` | array | No | Conditional execution rules | + +### Search/Replace Configuration + +| Field | Type | Description | +|-------|------|-------------| +| `words` | array | List of search/replace pairs | +| `words[].search` | string | Regex pattern to search for | +| `words[].replace` | string | Replacement text | + +## Usage Examples + +### Basic Word Replacement + +```yaml +config: + words: + - search: "hello" + replace: "greetings" + - search: "goodbye" + replace: "farewell" +``` + +### Regex Pattern Matching + +```yaml +config: + words: + # Replace email addresses with placeholder + - search: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}" + replace: "[email]" + + # Replace phone numbers + - search: "\\b\\d{3}-\\d{3}-\\d{4}\\b" + replace: "[phone]" + + # Case-insensitive replacement + - search: "(?i)microsoft" + replace: "MS" +``` + +### Chained Transformations + +```yaml +config: + words: + # These apply in order + - search: "bad" + replace: "not good" + - search: "not good" + replace: "could be better" + # Result: "bad" → "not good" → "could be better" +``` + +## How It Works + +### Pre-fetch Hook +1. Receives prompt name and arguments +2. Applies all configured patterns to each argument value +3. Returns modified arguments for prompt rendering + +### Post-fetch Hook +1. Receives rendered prompt messages +2. Applies patterns to message content +3. Returns modified messages + +## Testing + +### Manual Testing + +1. Enable the plugin in your configuration +2. Create a test prompt: +```bash +curl -X POST http://localhost:4444/prompts \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "test_prompt", + "template": "User said: {{ message }}", + "argument_schema": { + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"] + } + }' +``` + +3. Test the replacement: +```bash +curl -X GET http://localhost:4444/prompts/test_prompt \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"message": "This is crap"}' + +# Expected: "User said: This is crud" +``` + +### Unit Testing + +```python +import pytest +from plugins.regex_filter.search_replace import SearchReplacePlugin, SearchReplaceConfig + +@pytest.mark.asyncio +async def test_search_replace(): + config = PluginConfig( + name="test", + kind="plugins.regex_filter.search_replace.SearchReplacePlugin", + version="0.1", + hooks=["prompt_pre_fetch"], + config={ + "words": [ + {"search": "foo", "replace": "bar"} + ] + } + ) + + plugin = SearchReplacePlugin(config) + payload = PromptPrehookPayload( + name="test", + args={"message": "foo is foo"} + ) + + result = await plugin.prompt_pre_fetch(payload, context) + assert result.modified_payload.args["message"] == "bar is bar" +``` + +## Performance Considerations + +- Patterns are compiled once during initialization +- Regex complexity affects performance +- Consider priority when chaining with other plugins +- Use specific prompt conditions to limit scope + +## Common Use Cases + +1. **Profanity Filter**: Replace inappropriate language +2. **Terminology Standardization**: Ensure consistent terms +3. **PII Redaction**: Simple pattern-based PII removal +4. **Format Normalization**: Standardize date/time formats +5. **Abbreviation Expansion**: Expand common abbreviations + +## Troubleshooting + +### Patterns Not Matching +- Check regex syntax and escaping +- Test patterns with online regex tools +- Enable debug logging to see transformations + +### Performance Issues +- Simplify complex regex patterns +- Reduce number of patterns +- Use prompt conditions to limit scope + +### Unexpected Results +- Remember patterns apply in order +- Check for overlapping patterns +- Test with simple inputs first + +## Available Hooks + +The plugin manifest declares support for: +- `prompt_pre_hook` - Before prompt retrieval +- `prompt_post_hook` - After prompt rendering +- `tool_pre_hook` - Before tool execution (not implemented) +- `tool_post_hook` - After tool execution (not implemented) + +Currently only prompt hooks are implemented. + +## Contributing + +To extend this plugin: + +1. Add new transformation strategies +2. Implement tool hooks +3. Add pattern validation +4. Create preset pattern libraries + +## License + +Apache-2.0 + +## Support + +For issues or questions, please open an issue in the MCP Gateway repository. diff --git a/plugins/examples/context_forge/internal/regex_filter/plugin-manifest.yaml b/plugins/examples/context_forge/internal/regex_filter/plugin-manifest.yaml new file mode 100644 index 0000000..faece74 --- /dev/null +++ b/plugins/examples/context_forge/internal/regex_filter/plugin-manifest.yaml @@ -0,0 +1,9 @@ +description: "Search replace plugin manifest." +author: "ContextForge" +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/context_forge/internal/regex_filter/search_replace.py b/plugins/examples/context_forge/internal/regex_filter/search_replace.py new file mode 100644 index 0000000..dfdb906 --- /dev/null +++ b/plugins/examples/context_forge/internal/regex_filter/search_replace.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +"""Location: ./plugins/regex_filter/search_replace.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Teryl Taylor + +Simple example plugin for searching and replacing text. +This module loads configurations for plugins. +""" + +# Standard +import re + +# Third-Party +from pydantic import BaseModel + +# First-Party +from mcpgateway.plugins.framework import ( + Plugin, + PluginConfig, + PluginContext, + PromptPosthookPayload, + PromptPosthookResult, + PromptPrehookPayload, + PromptPrehookResult, + ToolPostInvokePayload, + ToolPostInvokeResult, + ToolPreInvokePayload, + ToolPreInvokeResult, +) + + +class SearchReplace(BaseModel): + """Search and replace pattern configuration. + + Attributes: + search: Regular expression pattern to search for. + replace: Replacement text. + """ + + search: str + replace: str + + +class SearchReplaceConfig(BaseModel): + """Configuration for search and replace plugin. + + Attributes: + words: List of search and replace patterns to apply. + """ + + words: list[SearchReplace] + + +class SearchReplacePlugin(Plugin): + """Example search replace plugin.""" + + def __init__(self, config: PluginConfig): + """Initialize the search and replace plugin. + + Args: + config: Plugin configuration containing search/replace patterns. + """ + super().__init__(config) + self._srconfig = SearchReplaceConfig.model_validate(self._config.config) + self.__patterns = [] + for word in self._srconfig.words: + self.__patterns.append((r"{}".format(word.search), word.replace)) + + 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. + """ + if payload.args: + for pattern in self.__patterns: + for key in payload.args: + value = re.sub(pattern[0], pattern[1], payload.args[key]) + payload.args[key] = value + return PromptPrehookResult(modified_payload=payload) + + 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. + """ + + if payload.result.messages: + for index, message in enumerate(payload.result.messages): + for pattern in self.__patterns: + value = re.sub(pattern[0], pattern[1], message.content.text) + payload.result.messages[index].content.text = value + return PromptPosthookResult(modified_payload=payload) + + 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. + """ + print("tool_pre_invoke") + if payload.args: + for pattern in self.__patterns: + for key in payload.args: + if isinstance(payload.args[key], str): + value = re.sub(pattern[0], pattern[1], payload.args[key]) + payload.args[key] = value + return ToolPreInvokeResult(modified_payload=payload) + + 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. + """ + print("tool_post_invoke") + if payload.result and isinstance(payload.result, dict): + for pattern in self.__patterns: + for key in payload.result: + if isinstance(payload.result[key], str): + value = re.sub(pattern[0], pattern[1], payload.result[key]) + payload.result[key] = value + elif isinstance(payload.result[key], list): + for i in range(len(payload.result[key])): + if isinstance(payload.result[key][i], dict): + for k in payload.result[key][i]: + if isinstance(payload.result[key][i][k], str): + value = re.sub(pattern[0], pattern[1], payload.result[key][i][k]) + payload.result[key][i][k] = value + elif payload.result and isinstance(payload.result, str): + for pattern in self.__patterns: + payload.result = re.sub(pattern[0], pattern[1], payload.result) + return ToolPostInvokeResult(modified_payload=payload) diff --git a/resources/config/config.yaml b/resources/config/config.yaml index 812a5e7..1787021 100644 --- a/resources/config/config.yaml +++ b/resources/config/config.yaml @@ -2,11 +2,12 @@ plugins: # Self-contained Search Replace Plugin - name: "ReplaceBadWordsPlugin" - kind: "plugins.regex_filter.search_replace.SearchReplacePlugin" + kind: "plugins.examples.context_forge.internal.regex_filter.search_replace.SearchReplacePlugin" description: "A plugin for finding and replacing words." version: "0.1.0" author: "Teryl Taylor" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + #hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + hooks: ["tool_post_invoke"] tags: ["plugin", "transformer", "regex", "search-and-replace", "pre-post"] mode: "enforce" # enforce | permissive | disabled priority: 150 diff --git a/src/server.py b/src/server.py index 674a05f..e9ca1c7 100644 --- a/src/server.py +++ b/src/server.py @@ -46,10 +46,10 @@ async def getToolPostInvokeResponse(body): # for content in body["result"]["content"]: logger.debug("**** Tool Post Invoke ****") - payload = ToolPostInvokePayload(name="replaceme", result=body) + payload = ToolPostInvokePayload(name="replaceme", result=body["result"]) # TODO: hard-coded ids logger.debug("**** Tool Post Invoke result ****") - logger.deub(payload) + logger.debug(payload) global_context = GlobalContext(request_id="1", server_id="2") result, contexts = await manager.invoke_hook( ToolHookType.TOOL_POST_INVOKE, payload, global_context=global_context @@ -66,7 +66,7 @@ async def getToolPostInvokeResponse(body): else: result_payload = result.modified_payload if result_payload is not None: - body = result_payload.result + body["result"] = result_payload.result else: body = None body_resp = ep.ProcessingResponse( @@ -104,11 +104,9 @@ async def getToolPreInvokeResponse(body): ) ) else: - logger.debug(result) - print(result) result_payload = result.modified_payload if result_payload is not None and result_payload.args is not None: - body["params"]["arguments"] = result_payload.args + body["params"]["arguments"] = result_payload.args["tool_args"] # else: # body["params"]["arguments"] = None @@ -253,8 +251,10 @@ async def Process( if data: # List can be empty data = json.loads(data[0].strip("data:")) # TODO: check for tool call - if "result" in data: + if "result" in data and "content" in data["result"]: body_resp = await getToolPostInvokeResponse(data) + #elif "result" in data and "messages" in data["result"]: #prompts + #elif "result" in data and "resources" in data["result"]: #resources else: body_resp = ep.ProcessingResponse( response_body=ep.BodyResponse(