mcp-server-framework 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_server_factory/__init__.py +11 -0
- mcp_server_factory/commands.py +80 -0
- mcp_server_factory/factory.py +113 -0
- mcp_server_factory/loader.py +10 -0
- mcp_server_factory/plugins/__init__.py +1 -0
- mcp_server_factory/plugins/logging.py +121 -0
- mcp_server_factory/plugins/management.py +52 -0
- mcp_server_factory/py.typed +0 -0
- mcp_server_factory/tracker.py +8 -0
- mcp_server_framework/__init__.py +36 -0
- mcp_server_framework/config.py +109 -0
- mcp_server_framework/health.py +153 -0
- mcp_server_framework/logging.py +60 -0
- mcp_server_framework/oauth.py +179 -0
- mcp_server_framework/plugins/__init__.py +16 -0
- mcp_server_framework/plugins/loader.py +205 -0
- mcp_server_framework/plugins/models.py +21 -0
- mcp_server_framework/plugins/tracker.py +160 -0
- mcp_server_framework/py.typed +0 -0
- mcp_server_framework/server.py +116 -0
- mcp_server_framework/utils/__init__.py +1 -0
- mcp_server_framework-1.1.0.dist-info/METADATA +429 -0
- mcp_server_framework-1.1.0.dist-info/RECORD +38 -0
- mcp_server_framework-1.1.0.dist-info/WHEEL +5 -0
- mcp_server_framework-1.1.0.dist-info/entry_points.txt +3 -0
- mcp_server_framework-1.1.0.dist-info/licenses/LICENSE +21 -0
- mcp_server_framework-1.1.0.dist-info/top_level.txt +3 -0
- mcp_server_proxy/__init__.py +11 -0
- mcp_server_proxy/cli.py +54 -0
- mcp_server_proxy/client.py +74 -0
- mcp_server_proxy/commands.py +21 -0
- mcp_server_proxy/management.py +129 -0
- mcp_server_proxy/plugins/__init__.py +0 -0
- mcp_server_proxy/plugins/management.py +136 -0
- mcp_server_proxy/proxy.py +237 -0
- mcp_server_proxy/py.typed +0 -0
- mcp_server_proxy/serve.py +118 -0
- mcp_server_proxy/tool_log.py +104 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""mcp_server_factory — Builds MCP servers from tool modules.
|
|
2
|
+
|
|
3
|
+
Everything is a plugin. External tools and internal management
|
|
4
|
+
commands use the same interface: register(mcp, config).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "1.1.0"
|
|
8
|
+
|
|
9
|
+
from .factory import Factory
|
|
10
|
+
|
|
11
|
+
__all__ = ["Factory"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""CLI — Entry point for the MCP Server Factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from mcp_server_framework import load_config, create_server, run_server, start_health_server
|
|
11
|
+
from mcp_server_framework.plugins.loader import add_plugin_dir
|
|
12
|
+
from mcp_server_framework.plugins.tracker import set_log_callback
|
|
13
|
+
from .factory import Factory
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_args() -> argparse.Namespace:
|
|
19
|
+
parser = argparse.ArgumentParser(prog="mcp-factory", description="MCP Server Factory — loads tool modules as plugins.")
|
|
20
|
+
parser.add_argument("--plugins", "-p", nargs="+", default=[], help="Plugin names to load")
|
|
21
|
+
parser.add_argument("--config", "-c", type=Path, default=None, help="YAML config file")
|
|
22
|
+
parser.add_argument("--http", type=int, metavar="PORT", help="HTTP transport on given port")
|
|
23
|
+
parser.add_argument("--health-port", type=int, metavar="PORT", help="Health endpoint port")
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--plugin-dir", "-d", type=Path, action="append", default=[],
|
|
26
|
+
help="Additional plugin search directory (can be repeated)",
|
|
27
|
+
)
|
|
28
|
+
return parser.parse_args()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main() -> None:
|
|
32
|
+
args = parse_args()
|
|
33
|
+
config = load_config(args.config)
|
|
34
|
+
if args.http:
|
|
35
|
+
config["transport"] = "http"
|
|
36
|
+
config["port"] = args.http
|
|
37
|
+
if args.health_port:
|
|
38
|
+
config["health_port"] = args.health_port
|
|
39
|
+
if config.get("server_name") == "MCP Server":
|
|
40
|
+
config["server_name"] = "MCP Factory"
|
|
41
|
+
|
|
42
|
+
from mcp_server_framework import setup_logging
|
|
43
|
+
setup_logging(
|
|
44
|
+
level=config.get("log_level", "INFO"),
|
|
45
|
+
json_format=config.get("log_format") == "json",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
plugin_names = args.plugins or config.get("plugins_load", [])
|
|
49
|
+
plugins_config = config.get("plugins", {})
|
|
50
|
+
if not plugin_names and isinstance(plugins_config, dict):
|
|
51
|
+
plugin_names = [n for n, c in plugins_config.items() if isinstance(c, dict) and c.get("enabled", True)]
|
|
52
|
+
if not plugin_names:
|
|
53
|
+
print("Error: No plugins specified (use --plugins or config)")
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
# Set up plugin infrastructure
|
|
57
|
+
add_plugin_dir(Path(__file__).parent.parent.parent / "plugins")
|
|
58
|
+
for plugin_dir in args.plugin_dir:
|
|
59
|
+
add_plugin_dir(plugin_dir.resolve())
|
|
60
|
+
from .plugins.logging import log_settings
|
|
61
|
+
set_log_callback(log_settings.log_call)
|
|
62
|
+
|
|
63
|
+
mcp = create_server(config)
|
|
64
|
+
factory = Factory(mcp, config)
|
|
65
|
+
config["_factory"] = factory
|
|
66
|
+
factory.load_internals()
|
|
67
|
+
loaded = factory.load_externals(plugin_names)
|
|
68
|
+
if not loaded:
|
|
69
|
+
logger.error("No plugins loaded successfully — aborting")
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
logger.info("%d plugin(s) loaded: %s", len(loaded), ", ".join(loaded))
|
|
72
|
+
|
|
73
|
+
if config.get("transport") != "stdio":
|
|
74
|
+
start_health_server(port=config["health_port"], title=f"{config['server_name']} Health")
|
|
75
|
+
|
|
76
|
+
run_server(mcp, config)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
main()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Factory — Core orchestrator for plugin loading and management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
from mcp_server_framework.plugins import LoadedPlugin, load_module, find_register, ToolTracker
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Factory:
|
|
17
|
+
"""Plugin-based MCP server factory."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, mcp: FastMCP, config: dict[str, Any]):
|
|
20
|
+
self.mcp = mcp
|
|
21
|
+
self.config = config
|
|
22
|
+
self.plugins: dict[str, LoadedPlugin] = {}
|
|
23
|
+
self._all_tools: set[str] = set()
|
|
24
|
+
self._all_resources: set[str] = set()
|
|
25
|
+
self._all_prompts: set[str] = set()
|
|
26
|
+
|
|
27
|
+
def load_internals(self) -> None:
|
|
28
|
+
"""Load internal factory plugins (management, logging)."""
|
|
29
|
+
from .plugins import management, logging as factory_logging
|
|
30
|
+
internal_config = {"_factory": self}
|
|
31
|
+
self._load_module(management, "factory_management", internal_config, internal=True)
|
|
32
|
+
self._load_module(factory_logging, "factory_logging", internal_config, internal=True)
|
|
33
|
+
|
|
34
|
+
def load_externals(self, plugin_names: list[str]) -> list[str]:
|
|
35
|
+
"""Load external plugins by name. Returns list of successfully loaded names."""
|
|
36
|
+
loaded = []
|
|
37
|
+
plugins_config = self.config.get("plugins", {})
|
|
38
|
+
for name in plugin_names:
|
|
39
|
+
plugin_config = plugins_config.get(name, {})
|
|
40
|
+
if isinstance(plugin_config, dict) and not plugin_config.get("enabled", True):
|
|
41
|
+
logger.info("Plugin '%s' disabled in config, skipping", name)
|
|
42
|
+
continue
|
|
43
|
+
module = load_module(name, self.config)
|
|
44
|
+
if module is None:
|
|
45
|
+
logger.error("Plugin '%s' not found", name)
|
|
46
|
+
continue
|
|
47
|
+
if self._load_module(module, name, plugin_config, internal=False):
|
|
48
|
+
loaded.append(name)
|
|
49
|
+
return loaded
|
|
50
|
+
|
|
51
|
+
def _load_module(self, module: ModuleType, name: str, plugin_config: dict, internal: bool) -> bool:
|
|
52
|
+
"""Load a single module: find register(), track tools, check collisions."""
|
|
53
|
+
register_fn = find_register(module)
|
|
54
|
+
if register_fn is None:
|
|
55
|
+
logger.error("Plugin '%s' has no register(mcp, config) function", name)
|
|
56
|
+
return False
|
|
57
|
+
tracker = ToolTracker(self.mcp)
|
|
58
|
+
try:
|
|
59
|
+
register_fn(tracker, plugin_config)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.error("Plugin '%s' register() failed: %s", name, e)
|
|
62
|
+
return False
|
|
63
|
+
new_tools = tracker.registered_tools
|
|
64
|
+
new_resources = tracker.registered_resources
|
|
65
|
+
new_prompts = tracker.registered_prompts
|
|
66
|
+
collisions = self._all_tools & set(new_tools)
|
|
67
|
+
if collisions:
|
|
68
|
+
for tool_name in collisions:
|
|
69
|
+
owner = self._find_tool_owner(tool_name)
|
|
70
|
+
logger.error("Tool collision: '%s' already in plugin '%s', cannot load '%s'", tool_name, owner, name)
|
|
71
|
+
return False
|
|
72
|
+
res_collisions = self._all_resources & set(new_resources)
|
|
73
|
+
if res_collisions:
|
|
74
|
+
logger.error("Resource collision: %s, cannot load '%s'", res_collisions, name)
|
|
75
|
+
return False
|
|
76
|
+
prompt_collisions = self._all_prompts & set(new_prompts)
|
|
77
|
+
if prompt_collisions:
|
|
78
|
+
logger.error("Prompt collision: %s, cannot load '%s'", prompt_collisions, name)
|
|
79
|
+
return False
|
|
80
|
+
self._all_tools.update(new_tools)
|
|
81
|
+
self._all_resources.update(new_resources)
|
|
82
|
+
self._all_prompts.update(new_prompts)
|
|
83
|
+
self.plugins[name] = LoadedPlugin(
|
|
84
|
+
name=name, module=module, tools=new_tools,
|
|
85
|
+
resources=new_resources, prompts=new_prompts,
|
|
86
|
+
loaded_at=datetime.now(), config=plugin_config, internal=internal,
|
|
87
|
+
)
|
|
88
|
+
label = "internal" if internal else "external"
|
|
89
|
+
logger.info("Plugin '%s' loaded (%s): %d tools", name, label, len(new_tools))
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
def _find_tool_owner(self, tool_name: str) -> str:
|
|
93
|
+
for plugin in self.plugins.values():
|
|
94
|
+
if tool_name in plugin.tools:
|
|
95
|
+
return plugin.name
|
|
96
|
+
return "unknown"
|
|
97
|
+
|
|
98
|
+
def get_plugin_summary(self) -> dict[str, Any]:
|
|
99
|
+
return {
|
|
100
|
+
"total_tools": len(self._all_tools),
|
|
101
|
+
"total_resources": len(self._all_resources),
|
|
102
|
+
"total_prompts": len(self._all_prompts),
|
|
103
|
+
"total_plugins": len(self.plugins),
|
|
104
|
+
"plugins": {
|
|
105
|
+
name: {
|
|
106
|
+
"tools": p.tools, "tool_count": len(p.tools),
|
|
107
|
+
"resources": p.resources, "resource_count": len(p.resources),
|
|
108
|
+
"prompts": p.prompts, "prompt_count": len(p.prompts),
|
|
109
|
+
"internal": p.internal, "loaded_at": p.loaded_at.isoformat(),
|
|
110
|
+
}
|
|
111
|
+
for name, p in self.plugins.items()
|
|
112
|
+
},
|
|
113
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Loader — Re-export from framework for backwards compatibility."""
|
|
2
|
+
|
|
3
|
+
from mcp_server_framework.plugins.loader import (
|
|
4
|
+
load_module,
|
|
5
|
+
find_register,
|
|
6
|
+
set_plugin_dirs,
|
|
7
|
+
add_plugin_dir,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = ["load_module", "find_register", "set_plugin_dirs", "add_plugin_dir"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal factory plugins."""
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Logging plugin — factory__log, factory__transcript."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
LOG_DIR = Path.home() / ".mcp_factory"
|
|
9
|
+
DEFAULT_LOG_FILE = LOG_DIR / "factory.log"
|
|
10
|
+
TRANSCRIPT_DIR = LOG_DIR / "transcripts"
|
|
11
|
+
MAX_LOG_SIZE = 5 * 1024 * 1024
|
|
12
|
+
LOG_BACKUP_COUNT = 3
|
|
13
|
+
MAX_TRANSCRIPT_SIZE = 10 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LogSettings:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.log_enabled: bool = False
|
|
19
|
+
self.log_file: Optional[Path] = None
|
|
20
|
+
self.transcript_enabled: bool = False
|
|
21
|
+
self.transcript_file: Optional[Path] = None
|
|
22
|
+
|
|
23
|
+
def set_logging(self, enabled: bool) -> str:
|
|
24
|
+
if enabled:
|
|
25
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
self.log_file = DEFAULT_LOG_FILE
|
|
27
|
+
self.log_enabled = True
|
|
28
|
+
return f"Log ON → {self.log_file}"
|
|
29
|
+
self.log_enabled = False
|
|
30
|
+
self.log_file = None
|
|
31
|
+
return "Log OFF"
|
|
32
|
+
|
|
33
|
+
def start_transcript(self) -> str:
|
|
34
|
+
TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
now = datetime.now()
|
|
36
|
+
filename = now.strftime("%Y-%m-%d-%H-%M-%S.md")
|
|
37
|
+
self.transcript_file = TRANSCRIPT_DIR / filename
|
|
38
|
+
self.transcript_enabled = True
|
|
39
|
+
header = f"# MCP Factory Transcript\n**Started:** {now.strftime('%Y-%m-%d %H:%M:%S')}\n\n---\n\n"
|
|
40
|
+
self.transcript_file.write_text(header, encoding="utf-8")
|
|
41
|
+
return f"Transcript ON → {self.transcript_file}"
|
|
42
|
+
|
|
43
|
+
def stop_transcript(self) -> str:
|
|
44
|
+
if not self.transcript_enabled:
|
|
45
|
+
return "No active transcript"
|
|
46
|
+
if self.transcript_file and self.transcript_file.exists():
|
|
47
|
+
with open(self.transcript_file, "a", encoding="utf-8") as f:
|
|
48
|
+
f.write(f"\n---\n**Ended:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
|
49
|
+
result = f"Transcript OFF (saved: {self.transcript_file})"
|
|
50
|
+
self.transcript_enabled = False
|
|
51
|
+
self.transcript_file = None
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
def log_call(self, tool: str, params: dict, result: str, success: bool) -> None:
|
|
55
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
56
|
+
status = "OK" if success else "FAIL"
|
|
57
|
+
if self.log_enabled and self.log_file:
|
|
58
|
+
params_str = str(params)[:100] + ("..." if len(str(params)) > 100 else "")
|
|
59
|
+
try:
|
|
60
|
+
self._rotate_if_needed()
|
|
61
|
+
with open(self.log_file, "a", encoding="utf-8") as f:
|
|
62
|
+
f.write(f"[{timestamp}] {status} {tool} {params_str}\n")
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
if self.transcript_enabled and self.transcript_file:
|
|
66
|
+
self._write_transcript(timestamp, tool, params, result, success)
|
|
67
|
+
|
|
68
|
+
def _rotate_if_needed(self) -> None:
|
|
69
|
+
if not self.log_file or not self.log_file.exists():
|
|
70
|
+
return
|
|
71
|
+
if self.log_file.stat().st_size < MAX_LOG_SIZE:
|
|
72
|
+
return
|
|
73
|
+
for i in range(LOG_BACKUP_COUNT, 0, -1):
|
|
74
|
+
old = self.log_file.with_suffix(f".log.{i}")
|
|
75
|
+
if old.exists():
|
|
76
|
+
if i == LOG_BACKUP_COUNT:
|
|
77
|
+
old.unlink()
|
|
78
|
+
else:
|
|
79
|
+
old.rename(self.log_file.with_suffix(f".log.{i + 1}"))
|
|
80
|
+
self.log_file.rename(self.log_file.with_suffix(".log.1"))
|
|
81
|
+
|
|
82
|
+
def _write_transcript(self, timestamp, tool, params, result, success) -> None:
|
|
83
|
+
if not self.transcript_file:
|
|
84
|
+
return
|
|
85
|
+
if self.transcript_file.exists() and self.transcript_file.stat().st_size > MAX_TRANSCRIPT_SIZE:
|
|
86
|
+
self.stop_transcript()
|
|
87
|
+
self.start_transcript()
|
|
88
|
+
status_emoji = "✓" if success else "✗"
|
|
89
|
+
params_fmt = "\n".join(f" {k}: {v}" for k, v in params.items()) if params else " (none)"
|
|
90
|
+
result_text = result[:50000] + f"\n\n... (truncated, {len(result)} chars)" if len(result) > 50000 else result
|
|
91
|
+
entry = f"\n## [{timestamp}] {status_emoji} `{tool}`\n\n**Parameters:**\n{params_fmt}\n\n**Result:**\n```\n{result_text}\n```\n\n---\n"
|
|
92
|
+
try:
|
|
93
|
+
with open(self.transcript_file, "a", encoding="utf-8") as f:
|
|
94
|
+
f.write(entry)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
log_settings = LogSettings()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def register(mcp, config: dict[str, Any]) -> None:
|
|
103
|
+
@mcp.tool()
|
|
104
|
+
def factory__log(mode: str = "status") -> str:
|
|
105
|
+
"""Control tool call logging. Args: mode: 'on', 'off', or 'status'"""
|
|
106
|
+
if mode == "on":
|
|
107
|
+
return log_settings.set_logging(True)
|
|
108
|
+
elif mode == "off":
|
|
109
|
+
return log_settings.set_logging(False)
|
|
110
|
+
return f"Log {'ON → ' + str(log_settings.log_file) if log_settings.log_enabled else 'OFF'}"
|
|
111
|
+
|
|
112
|
+
@mcp.tool()
|
|
113
|
+
def factory__transcript(mode: str = "status") -> str:
|
|
114
|
+
"""Control full transcript recording. Args: mode: 'on', 'off', or 'status'"""
|
|
115
|
+
if mode == "on":
|
|
116
|
+
if log_settings.transcript_enabled:
|
|
117
|
+
return f"Transcript already active: {log_settings.transcript_file}"
|
|
118
|
+
return log_settings.start_transcript()
|
|
119
|
+
elif mode == "off":
|
|
120
|
+
return log_settings.stop_transcript()
|
|
121
|
+
return f"Transcript {'ON → ' + str(log_settings.transcript_file) if log_settings.transcript_enabled else 'OFF'}"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Management plugin — factory__status, factory__list."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any
|
|
5
|
+
from mcp_server_factory import __version__
|
|
6
|
+
|
|
7
|
+
_factory = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register(mcp, config: dict[str, Any]) -> None:
|
|
11
|
+
global _factory
|
|
12
|
+
_factory = config.get("_factory")
|
|
13
|
+
|
|
14
|
+
@mcp.tool()
|
|
15
|
+
def factory__status() -> str:
|
|
16
|
+
"""Show factory status: version, transport, plugins, tools."""
|
|
17
|
+
if _factory is None:
|
|
18
|
+
return "Factory reference not available"
|
|
19
|
+
summary = _factory.get_plugin_summary()
|
|
20
|
+
fc = _factory.config
|
|
21
|
+
lines = [
|
|
22
|
+
f"MCP Factory v{__version__}",
|
|
23
|
+
f" Server: {fc.get('server_name', 'MCP Factory')}",
|
|
24
|
+
f" Transport: {fc.get('transport', 'stdio')}",
|
|
25
|
+
]
|
|
26
|
+
if fc.get("transport") != "stdio":
|
|
27
|
+
lines.append(f" Port: {fc.get('port', 'n/a')}")
|
|
28
|
+
lines.append(f" Health: {fc.get('health_port', 'n/a')}")
|
|
29
|
+
lines.append(f" Plugins: {summary['total_plugins']} loaded")
|
|
30
|
+
lines.append(f" Tools: {summary['total_tools']} registered")
|
|
31
|
+
from .logging import log_settings
|
|
32
|
+
lines.append(f" Log: {'ON' if log_settings.log_enabled else 'OFF'}")
|
|
33
|
+
lines.append(f" Transcript: {'ON' if log_settings.transcript_enabled else 'OFF'}")
|
|
34
|
+
return "\n".join(lines)
|
|
35
|
+
|
|
36
|
+
@mcp.tool()
|
|
37
|
+
def factory__list() -> str:
|
|
38
|
+
"""List loaded plugins and their tools."""
|
|
39
|
+
if _factory is None:
|
|
40
|
+
return "Factory reference not available"
|
|
41
|
+
summary = _factory.get_plugin_summary()
|
|
42
|
+
lines = ["Loaded plugins:\n"]
|
|
43
|
+
for name, info in summary["plugins"].items():
|
|
44
|
+
label = " (internal)" if info["internal"] else ""
|
|
45
|
+
tools_str = ", ".join(info["tools"][:5])
|
|
46
|
+
if len(info["tools"]) > 5:
|
|
47
|
+
tools_str += f", ... (+{len(info['tools']) - 5})"
|
|
48
|
+
lines.append(f" {name:<20} {info['tool_count']:>3} tools{label}")
|
|
49
|
+
lines.append(f" [{tools_str}]")
|
|
50
|
+
lines.append(f" {'─' * 40}")
|
|
51
|
+
lines.append(f" Total: {summary['total_tools']} tools, {summary['total_plugins']} plugins")
|
|
52
|
+
return "\n".join(lines)
|
|
File without changes
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""ToolTracker — Re-export from framework for backwards compatibility.
|
|
2
|
+
|
|
3
|
+
The Factory hooks into the tracker's logging via set_log_callback().
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from mcp_server_framework.plugins.tracker import ToolTracker, set_log_callback
|
|
7
|
+
|
|
8
|
+
__all__ = ["ToolTracker", "set_log_callback"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""mcp_server_framework — Shared framework for MCP servers.
|
|
2
|
+
|
|
3
|
+
Provides transport, health monitoring, configuration and plugin infrastructure.
|
|
4
|
+
Imported as a library — by standalone MCP servers, the Factory and the Proxy.
|
|
5
|
+
|
|
6
|
+
Public API:
|
|
7
|
+
load_config(path) → Config from YAML + ENV
|
|
8
|
+
create_server(config) → FastMCP instance
|
|
9
|
+
run_server(mcp, config) → Start server (stdio or HTTP)
|
|
10
|
+
start_health_server(port, ...) → Start health thread
|
|
11
|
+
create_health_app(...) → Create health app directly
|
|
12
|
+
|
|
13
|
+
Plugin API (via mcp_server_framework.plugins):
|
|
14
|
+
LoadedPlugin → Plugin record dataclass
|
|
15
|
+
load_module(name, config) → Resolve and import plugin
|
|
16
|
+
find_register(module) → Find register() function
|
|
17
|
+
ToolTracker(mcp) → Proxy that tracks tool registrations
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
__version__ = "1.1.0"
|
|
21
|
+
|
|
22
|
+
from .config import load_config
|
|
23
|
+
from .server import create_server, run_server
|
|
24
|
+
from .health import start_health_server, create_health_app
|
|
25
|
+
from .logging import setup_logging
|
|
26
|
+
from .oauth import IntrospectionTokenVerifier
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"load_config",
|
|
30
|
+
"create_server",
|
|
31
|
+
"run_server",
|
|
32
|
+
"start_health_server",
|
|
33
|
+
"create_health_app",
|
|
34
|
+
"setup_logging",
|
|
35
|
+
"IntrospectionTokenVerifier",
|
|
36
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Configuration — YAML + environment variables.
|
|
2
|
+
|
|
3
|
+
Three-tier merge: Defaults → YAML → ENV (last wins).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import copy
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_bool(value: str) -> bool:
|
|
18
|
+
"""Parse bool from ENV string."""
|
|
19
|
+
return value.lower() in ("true", "1", "yes", "on")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Immutable defaults — mutable values (tools) are
|
|
23
|
+
# deep-copied in load_config() for each call.
|
|
24
|
+
_DEFAULTS: dict[str, Any] = {
|
|
25
|
+
"server_name": "MCP Server",
|
|
26
|
+
"version": "0.1.0",
|
|
27
|
+
"instructions": "",
|
|
28
|
+
"service_type": "generic",
|
|
29
|
+
"log_level": "INFO",
|
|
30
|
+
# Transport
|
|
31
|
+
"host": "0.0.0.0",
|
|
32
|
+
"port": 12201,
|
|
33
|
+
"transport": "stdio",
|
|
34
|
+
# Health
|
|
35
|
+
"health_port": None, # Default: port + 1
|
|
36
|
+
# OAuth (enabled by default for HTTP, ignored for stdio)
|
|
37
|
+
"oauth_enabled": True,
|
|
38
|
+
"oauth_server_url": None,
|
|
39
|
+
"oauth_public_url": None,
|
|
40
|
+
# Tool names (for registry/monitoring)
|
|
41
|
+
"tools": [],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# ENV key → (config key, type cast)
|
|
45
|
+
_ENV_MAPPING: dict[str, tuple[str, type]] = {
|
|
46
|
+
"MCP_SERVER_NAME": ("server_name", str),
|
|
47
|
+
"MCP_LOG_LEVEL": ("log_level", str),
|
|
48
|
+
"MCP_HOST": ("host", str),
|
|
49
|
+
"MCP_PORT": ("port", int),
|
|
50
|
+
"MCP_HEALTH_PORT": ("health_port", int),
|
|
51
|
+
"MCP_TRANSPORT": ("transport", str),
|
|
52
|
+
"MCP_OAUTH_ENABLED": ("oauth_enabled", _parse_bool),
|
|
53
|
+
"MCP_OAUTH_SERVER_URL": ("oauth_server_url", str),
|
|
54
|
+
"MCP_PUBLIC_URL": ("oauth_public_url", str),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_config(
|
|
59
|
+
config_path: Path | None = None,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""Load configuration: Defaults → YAML → ENV.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
config_path: Optional path to YAML file.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Merged config dict (own copy, safe to mutate).
|
|
68
|
+
"""
|
|
69
|
+
config = copy.deepcopy(_DEFAULTS)
|
|
70
|
+
|
|
71
|
+
if config_path and config_path.exists():
|
|
72
|
+
_load_yaml(config, config_path)
|
|
73
|
+
|
|
74
|
+
_apply_env(config)
|
|
75
|
+
|
|
76
|
+
# Computed defaults
|
|
77
|
+
if config["health_port"] is None:
|
|
78
|
+
config["health_port"] = config["port"] + 1
|
|
79
|
+
|
|
80
|
+
return config
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _load_yaml(
|
|
84
|
+
config: dict[str, Any],
|
|
85
|
+
path: Path,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Load YAML file into config dict."""
|
|
88
|
+
try:
|
|
89
|
+
import yaml
|
|
90
|
+
except ImportError:
|
|
91
|
+
logger.warning("PyYAML not installed, YAML config ignored")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
with open(path) as f:
|
|
95
|
+
yaml_config = yaml.safe_load(f) or {}
|
|
96
|
+
config.update(yaml_config)
|
|
97
|
+
logger.info("Config loaded: %s", path)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _apply_env(config: dict[str, Any]) -> None:
|
|
101
|
+
"""Apply ENV overrides to config."""
|
|
102
|
+
for env_key, (config_key, cast) in _ENV_MAPPING.items():
|
|
103
|
+
value = os.getenv(env_key)
|
|
104
|
+
if value is None:
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
config[config_key] = cast(value)
|
|
108
|
+
except (ValueError, TypeError):
|
|
109
|
+
logger.warning("ENV %s=%r: invalid value", env_key, value)
|