Coverage for mcpgateway / plugins / framework / base.py: 99%
153 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-11 07:10 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-11 07:10 +0000
1# -*- coding: utf-8 -*-
2"""Location: ./mcpgateway/plugins/framework/base.py
3Copyright 2025
4SPDX-License-Identifier: Apache-2.0
5-Authors: Teryl Taylor, Mihai Criveti
7Base plugin implementation.
8This module implements the base plugin object.
9"""
11# Standard
12from abc import ABC
13from typing import Awaitable, Callable, Optional, Union
14import uuid
16# First-Party
17from mcpgateway.plugins.framework.errors import PluginError
18from mcpgateway.plugins.framework.models import PluginCondition, PluginConfig, PluginContext, PluginErrorModel, PluginMode, PluginPayload, PluginResult
20# pylint: disable=import-outside-toplevel
23class Plugin(ABC):
24 """Base plugin object for pre/post processing of inputs and outputs at various locations throughout the server.
26 Examples:
27 >>> from mcpgateway.plugins.framework import PluginConfig, PluginMode
28 >>> from mcpgateway.plugins.framework.hooks.prompts import PromptHookType
29 >>> config = PluginConfig(
30 ... name="test_plugin",
31 ... description="Test plugin",
32 ... author="test",
33 ... kind="mcpgateway.plugins.framework.Plugin",
34 ... version="1.0.0",
35 ... hooks=[PromptHookType.PROMPT_PRE_FETCH],
36 ... tags=["test"],
37 ... mode=PluginMode.ENFORCE,
38 ... priority=50
39 ... )
40 >>> plugin = Plugin(config)
41 >>> plugin.name
42 'test_plugin'
43 >>> plugin.priority
44 50
45 >>> plugin.mode
46 <PluginMode.ENFORCE: 'enforce'>
47 >>> PromptHookType.PROMPT_PRE_FETCH in plugin.hooks
48 True
49 """
51 def __init__(
52 self,
53 config: PluginConfig,
54 hook_payloads: Optional[dict[str, PluginPayload]] = None,
55 hook_results: Optional[dict[str, PluginResult]] = None,
56 ) -> None:
57 """Initialize a plugin with a configuration and context.
59 Args:
60 config: The plugin configuration
61 hook_payloads: optional mapping of hookpoints to payloads for the plugin.
62 Used for external plugins for converting json to pydantic.
63 hook_results: optional mapping of hookpoints to result types for the plugin.
64 Used for external plugins for converting json to pydantic.
66 Examples:
67 >>> from mcpgateway.plugins.framework import PluginConfig
68 >>> from mcpgateway.plugins.framework.hooks.prompts import PromptHookType
69 >>> config = PluginConfig(
70 ... name="simple_plugin",
71 ... description="Simple test",
72 ... author="test",
73 ... kind="test.Plugin",
74 ... version="1.0.0",
75 ... hooks=[PromptHookType.PROMPT_POST_FETCH],
76 ... tags=["simple"]
77 ... )
78 >>> plugin = Plugin(config)
79 >>> plugin._config.name
80 'simple_plugin'
81 """
82 self._config = config
83 self._hook_payloads = hook_payloads
84 self._hook_results = hook_results
86 @property
87 def priority(self) -> int:
88 """Return the plugin's priority.
90 Returns:
91 Plugin's priority.
92 """
93 return self._config.priority
95 @property
96 def config(self) -> PluginConfig:
97 """Return the plugin's configuration.
99 Returns:
100 Plugin's configuration.
101 """
102 return self._config
104 @property
105 def mode(self) -> PluginMode:
106 """Return the plugin's mode.
108 Returns:
109 Plugin's mode.
110 """
111 return self._config.mode
113 @property
114 def name(self) -> str:
115 """Return the plugin's name.
117 Returns:
118 Plugin's name.
119 """
120 return self._config.name
122 @property
123 def hooks(self) -> list[str]:
124 """Return the plugin's currently configured hooks.
126 Returns:
127 Plugin's configured hooks.
128 """
129 return self._config.hooks
131 @property
132 def tags(self) -> list[str]:
133 """Return the plugin's tags.
135 Returns:
136 Plugin's tags.
137 """
138 return self._config.tags
140 @property
141 def conditions(self) -> list[PluginCondition] | None:
142 """Return the plugin's conditions for operation.
144 Returns:
145 Plugin's conditions for executing.
146 """
147 return self._config.conditions
149 async def initialize(self) -> None:
150 """Initialize the plugin."""
152 async def shutdown(self) -> None:
153 """Plugin cleanup code."""
155 def json_to_payload(self, hook: str, payload: Union[str | dict]) -> PluginPayload:
156 """Converts a json payload to the proper pydantic payload object given a hook type. Used
157 mainly for serialization/deserialization of external plugin payloads.
159 Args:
160 hook: the hook type for which the payload needs converting.
161 payload: the payload as a string or dict.
163 Returns:
164 A pydantic payload object corresponding to the hook type.
166 Raises:
167 PluginError: if no payload type is defined.
168 """
169 hook_payload_type: type[PluginPayload] | None = None
171 # First try instance-level hook_payloads
172 if self._hook_payloads:
173 hook_payload_type = self._hook_payloads.get(hook, None) # type: ignore[assignment]
175 # Fall back to global registry
176 if not hook_payload_type:
177 # First-Party
178 from mcpgateway.plugins.framework.hooks.registry import get_hook_registry
180 registry = get_hook_registry()
181 hook_payload_type = registry.get_payload_type(hook)
183 if not hook_payload_type:
184 raise PluginError(error=PluginErrorModel(message=f"No payload defined for hook {hook}.", plugin_name=self.name))
186 if isinstance(payload, str):
187 return hook_payload_type.model_validate_json(payload)
188 return hook_payload_type.model_validate(payload)
190 def json_to_result(self, hook: str, result: Union[str | dict]) -> PluginResult:
191 """Converts a json result to the proper pydantic result object given a hook type. Used
192 mainly for serialization/deserialization of external plugin results.
194 Args:
195 hook: the hook type for which the result needs converting.
196 result: the result as a string or dict.
198 Returns:
199 A pydantic result object corresponding to the hook type.
201 Raises:
202 PluginError: if no result type is defined.
203 """
204 hook_result_type: type[PluginResult] | None = None
206 # First try instance-level hook_results
207 if self._hook_results:
208 hook_result_type = self._hook_results.get(hook, None) # type: ignore[assignment]
210 # Fall back to global registry
211 if not hook_result_type:
212 # First-Party
213 from mcpgateway.plugins.framework.hooks.registry import get_hook_registry
215 registry = get_hook_registry()
216 hook_result_type = registry.get_result_type(hook)
218 if not hook_result_type:
219 raise PluginError(error=PluginErrorModel(message=f"No result defined for hook {hook}.", plugin_name=self.name))
221 if isinstance(result, str):
222 return hook_result_type.model_validate_json(result)
223 return hook_result_type.model_validate(result)
226class PluginRef:
227 """Plugin reference which contains a uuid.
229 Examples:
230 >>> from mcpgateway.plugins.framework import PluginConfig, PluginMode
231 >>> from mcpgateway.plugins.framework.hooks.prompts import PromptHookType
232 >>> config = PluginConfig(
233 ... name="ref_test",
234 ... description="Reference test",
235 ... author="test",
236 ... kind="test.Plugin",
237 ... version="1.0.0",
238 ... hooks=[PromptHookType.PROMPT_PRE_FETCH],
239 ... tags=["ref", "test"],
240 ... mode=PluginMode.PERMISSIVE,
241 ... priority=100
242 ... )
243 >>> plugin = Plugin(config)
244 >>> ref = PluginRef(plugin)
245 >>> ref.name
246 'ref_test'
247 >>> ref.priority
248 100
249 >>> ref.mode
250 <PluginMode.PERMISSIVE: 'permissive'>
251 >>> len(ref.uuid) # UUID is a 32-character hex string
252 32
253 >>> ref.tags
254 ['ref', 'test']
255 """
257 def __init__(self, plugin: Plugin):
258 """Initialize a plugin reference.
260 Args:
261 plugin: The plugin to reference.
263 Examples:
264 >>> from mcpgateway.plugins.framework import PluginConfig
265 >>> from mcpgateway.plugins.framework.hooks.prompts import PromptHookType
266 >>> config = PluginConfig(
267 ... name="plugin_ref",
268 ... description="Test",
269 ... author="test",
270 ... kind="test.Plugin",
271 ... version="1.0.0",
272 ... hooks=[PromptHookType.PROMPT_POST_FETCH],
273 ... tags=[]
274 ... )
275 >>> plugin = Plugin(config)
276 >>> ref = PluginRef(plugin)
277 >>> ref._plugin.name
278 'plugin_ref'
279 >>> isinstance(ref._uuid, uuid.UUID)
280 True
281 """
282 self._plugin = plugin
283 self._uuid = uuid.uuid4()
285 @property
286 def plugin(self) -> Plugin:
287 """Return the underlying plugin.
289 Returns:
290 The underlying plugin.
291 """
292 return self._plugin
294 @property
295 def uuid(self) -> str:
296 """Return the plugin's UUID.
298 Returns:
299 Plugin's UUID.
300 """
301 return self._uuid.hex
303 @property
304 def priority(self) -> int:
305 """Returns the plugin's priority.
307 Returns:
308 Plugin's priority.
309 """
310 return self._plugin.priority
312 @property
313 def name(self) -> str:
314 """Return the plugin's name.
316 Returns:
317 Plugin's name.
318 """
319 return self._plugin.name
321 @property
322 def hooks(self) -> list[str]:
323 """Returns the plugin's currently configured hooks.
325 Returns:
326 Plugin's configured hooks.
327 """
328 return self._plugin.hooks
330 @property
331 def tags(self) -> list[str]:
332 """Return the plugin's tags.
334 Returns:
335 Plugin's tags.
336 """
337 return self._plugin.tags
339 @property
340 def conditions(self) -> list[PluginCondition] | None:
341 """Return the plugin's conditions for operation.
343 Returns:
344 Plugin's conditions for operation.
345 """
346 return self._plugin.conditions
348 @property
349 def mode(self) -> PluginMode:
350 """Return the plugin's mode.
352 Returns:
353 Plugin's mode.
354 """
355 return self.plugin.mode
358class HookRef:
359 """A Hook reference point with plugin and function."""
361 def __init__(self, hook: str, plugin_ref: PluginRef):
362 """Initialize a hook reference point.
364 Discovers the hook method using either:
365 1. Convention-based naming (method name matches hook type)
366 2. Decorator-based (@hook decorator with matching hook_type)
368 Args:
369 hook: name of the hook point (e.g., 'tool_pre_invoke').
370 plugin_ref: The reference to the plugin to hook.
372 Raises:
373 PluginError: If no method is found for the specified hook.
375 Examples:
376 >>> from mcpgateway.plugins.framework import PluginConfig
377 >>> config = PluginConfig(name="test", kind="test", version="1.0", author="test", hooks=["tool_pre_invoke"])
378 >>> plugin = Plugin(config)
379 >>> plugin_ref = PluginRef(plugin)
380 >>> # This would work if plugin has tool_pre_invoke method or @hook("tool_pre_invoke") decorator
381 """
382 # Standard
383 import inspect
385 # First-Party
386 from mcpgateway.plugins.framework.decorator import get_hook_metadata
388 self._plugin_ref = plugin_ref
389 self._hook = hook
391 # Try convention-based lookup first (method name matches hook type)
392 self._func: Callable[[PluginPayload, PluginContext], Awaitable[PluginResult]] | None = getattr(plugin_ref.plugin, hook, None)
394 # If not found by convention, scan for @hook decorated methods
395 if self._func is None:
396 for name, method in inspect.getmembers(plugin_ref.plugin, predicate=inspect.ismethod):
397 # Skip private/magic methods
398 if name.startswith("_"):
399 continue
401 # Check for @hook decorator metadata
402 metadata = get_hook_metadata(method)
403 if metadata and metadata.hook_type == hook:
404 self._func = method
405 break
407 # Raise error if hook method not found by either approach
408 if not self._func:
409 raise PluginError(
410 error=PluginErrorModel(
411 message=f"Plugin '{plugin_ref.plugin.name}' has no hook: '{hook}'. " f"Method must either be named '{hook}' or decorated with @hook('{hook}')",
412 plugin_name=plugin_ref.plugin.name,
413 )
414 )
416 # Validate hook method signature (parameter count and async)
417 self._validate_hook_signature(hook, self._func, plugin_ref.plugin.name)
419 def _validate_hook_signature(self, hook: str, func: Callable, plugin_name: str) -> None:
420 """Validate that the hook method has the correct signature.
422 Checks:
423 1. Method accepts correct number of parameters (self, payload, context)
424 2. Method is async (returns coroutine)
426 Args:
427 hook: The hook type being validated
428 func: The hook method to validate
429 plugin_name: Name of the plugin (for error messages)
431 Raises:
432 PluginError: If the signature is invalid
433 """
434 # Standard
435 import inspect
437 sig = inspect.signature(func)
438 params = list(sig.parameters.values())
440 # Check parameter count (should be: payload, context)
441 # Note: 'self' is not included in bound method signatures
442 if len(params) != 2:
443 raise PluginError(
444 error=PluginErrorModel(
445 message=f"Plugin '{plugin_name}' hook '{hook}' has invalid signature. "
446 f"Expected 2 parameters (payload, context), got {len(params)}: {list(sig.parameters.keys())}. "
447 f"Correct signature: async def {hook}(self, payload: PayloadType, context: PluginContext) -> ResultType",
448 plugin_name=plugin_name,
449 )
450 )
452 # Check that method is async
453 if not inspect.iscoroutinefunction(func):
454 raise PluginError(
455 error=PluginErrorModel(
456 message=f"Plugin '{plugin_name}' hook '{hook}' must be async. "
457 f"Method '{func.__name__}' is not a coroutine function. "
458 f"Use 'async def {func.__name__}(...)' instead of 'def {func.__name__}(...)'.",
459 plugin_name=plugin_name,
460 )
461 )
463 # ========== OPTIONAL: Type Hint Validation ==========
464 # Uncomment to enable strict type checking of payload and return types.
465 # This validates that type hints match the expected types from the hook registry.
466 # Pros: Catches type errors at plugin load time instead of runtime
467 # Cons: Requires all plugins to have type hints, adds validation overhead
468 #
469 # self._validate_type_hints(hook, func, params, plugin_name)
471 def _validate_type_hints(self, hook: str, func: Callable, params: list, plugin_name: str) -> None:
472 """Validate that type hints match expected payload and result types.
474 This is an optional validation that can be enabled to enforce type safety.
476 Args:
477 hook: The hook type being validated
478 func: The hook method to validate
479 params: List of function parameters
480 plugin_name: Name of the plugin (for error messages)
482 Raises:
483 PluginError: If type hints are missing or don't match expected types
484 """
485 # Standard
486 from typing import get_type_hints
488 # First-Party
489 from mcpgateway.plugins.framework.hooks.registry import get_hook_registry
491 # Get expected types from registry
492 registry = get_hook_registry()
493 expected_payload_type = registry.get_payload_type(hook)
494 expected_result_type = registry.get_result_type(hook)
496 # If hook is not registered in global registry, we can't validate types
497 if not expected_payload_type or not expected_result_type:
498 return
500 # Get type hints from the function
501 try:
502 hints = get_type_hints(func)
503 except Exception as e:
504 # Type hints might use forward references or unavailable types
505 # We'll skip validation rather than fail
506 # Standard
507 import logging
509 logger = logging.getLogger(__name__)
510 logger.debug("Could not extract type hints for plugin '%s' hook '%s': %s", plugin_name, hook, e)
511 return
513 # Validate payload parameter type (first parameter, since 'self' is not in params)
514 payload_param_name = params[0].name
515 if payload_param_name not in hints:
516 raise PluginError(
517 error=PluginErrorModel(
518 message=f"Plugin '{plugin_name}' hook '{hook}' missing type hint for parameter '{payload_param_name}'. " f"Expected: {payload_param_name}: {expected_payload_type.__name__}",
519 plugin_name=plugin_name,
520 )
521 )
523 actual_payload_type = hints[payload_param_name]
525 # Check if types match (exact match or subclass)
526 if actual_payload_type != expected_payload_type:
527 # Check for generic types or complex type hints
528 actual_type_str = str(actual_payload_type)
529 expected_type_str = expected_payload_type.__name__
531 # If the expected type name is in the string representation, it's probably OK
532 if expected_type_str not in actual_type_str: 532 ↛ 541line 532 didn't jump to line 541 because the condition on line 532 was always true
533 raise PluginError(
534 error=PluginErrorModel(
535 message=f"Plugin '{plugin_name}' hook '{hook}' parameter '{payload_param_name}' " f"has incorrect type hint. Expected: {expected_type_str}, Got: {actual_type_str}",
536 plugin_name=plugin_name,
537 )
538 )
540 # Validate return type
541 if "return" not in hints:
542 raise PluginError(
543 error=PluginErrorModel(
544 message=f"Plugin '{plugin_name}' hook '{hook}' missing return type hint. " f"Expected: -> {expected_result_type.__name__}",
545 plugin_name=plugin_name,
546 )
547 )
549 actual_return_type = hints["return"]
550 return_type_str = str(actual_return_type)
551 expected_return_str = expected_result_type.__name__
553 # For async functions, the return type might be wrapped in Coroutine or Awaitable
554 # We just check if the expected type is mentioned in the return type
555 if expected_return_str not in return_type_str and actual_return_type != expected_result_type: 555 ↛ exitline 555 didn't return from function '_validate_type_hints' because the condition on line 555 was always true
556 raise PluginError(
557 error=PluginErrorModel(
558 message=f"Plugin '{plugin_name}' hook '{hook}' has incorrect return type hint. " f"Expected: {expected_return_str}, Got: {return_type_str}",
559 plugin_name=plugin_name,
560 )
561 )
563 @property
564 def plugin_ref(self) -> PluginRef:
565 """The reference to the plugin object.
567 Returns:
568 A plugin reference.
569 """
570 return self._plugin_ref
572 @property
573 def name(self) -> str:
574 """The name of the hooking function.
576 Returns:
577 A plugin name.
578 """
579 return self._hook
581 @property
582 def hook(self) -> Callable[[PluginPayload, PluginContext], Awaitable[PluginResult]] | None:
583 """The hooking function that can be invoked within the reference.
585 Returns:
586 An awaitable hook function reference.
587 """
588 return self._func