diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 375ae78..0000000 --- a/PLAN.md +++ /dev/null @@ -1,285 +0,0 @@ -# mcp-forge — Open-Source MCP Server Framework - -## Vision - -A pip-installable CLI + runtime framework for bootstrapping production-ready MCP servers. -Like FastAPI for MCP — scaffold, develop, deploy in minutes on any cloud. - ---- - -## Monorepo Structure - -``` -mcp-forge/ -├── packages/ -│ ├── mcp-forge-core/ # Runtime library (provider interfaces + pure logic) -│ │ ├── pyproject.toml -│ │ └── src/mcp_forge_core/ -│ │ ├── providers/ # SOLID Provider Protocols (the key abstraction) -│ │ │ ├── __init__.py -│ │ │ ├── llm.py # LLMProvider Protocol -│ │ │ ├── vision.py # VisionProvider Protocol -│ │ │ ├── embedding.py # EmbeddingProvider Protocol -│ │ │ ├── cache.py # CacheProvider Protocol -│ │ │ ├── session.py # SessionProvider Protocol -│ │ │ ├── telemetry.py # TelemetryProvider Protocol -│ │ │ └── transcribe.py # TranscribeProvider Protocol -│ │ ├── config.py # MCPServerConfig (generic, no AWS) -│ │ ├── server_factory.py # create_mcp_app() (FastMCP + stdio) -│ │ ├── circuit_breaker.py # Resilience pattern (pure Python) -│ │ ├── retry.py # Exponential backoff (pure Python) -│ │ ├── models.py # Pydantic data models -│ │ ├── tool_data_store.py # Result compaction (uses CacheProvider) -│ │ └── similarity.py # Cosine similarity (pure math) -│ │ -│ ├── mcp-forge-aws/ # AWS provider implementations -│ │ ├── pyproject.toml # depends on mcp-forge-core, boto3, aioboto3 -│ │ └── src/mcp_forge_aws/ -│ │ ├── bedrock_llm.py # LLMProvider → AWS Bedrock (Claude) -│ │ ├── bedrock_vision.py # VisionProvider → Bedrock Vision -│ │ ├── bedrock_embedding.py # EmbeddingProvider → Titan Embeddings -│ │ ├── dynamodb_cache.py # CacheProvider → DynamoDB -│ │ ├── dynamodb_session.py # SessionProvider → DynamoDB -│ │ ├── cloudwatch.py # TelemetryProvider → CloudWatch -│ │ └── transcribe.py # TranscribeProvider → AWS Transcribe -│ │ -│ └── mcp-forge-cli/ # CLI scaffold tool -│ ├── pyproject.toml # depends on mcp-forge-core, click, jinja2 -│ └── src/mcp_forge_cli/ -│ ├── cli.py # Thin Click handler → ForgeOrchestrator -│ ├── orchestrator.py # Coordinates: validate → scaffold → registry -│ ├── scaffold.py # ScaffoldConfig + MCPServerScaffold -│ ├── registry.py # RegistryTarget Protocol + RegistryUpdater -│ ├── validators.py # Name rules, conflict detection -│ └── templates/ # Standalone Jinja2 templates (no mcp-shared) -│ ├── server.py.j2 -│ ├── config.py.j2 -│ ├── pyproject.toml.j2 -│ ├── tools___init__.py.j2 -│ ├── tools__sample.py.j2 -│ ├── tests__conftest.py.j2 -│ └── tests__unit__test_sample.py.j2 -│ -├── .github/workflows/ -│ ├── ci.yml # Test matrix: Python 3.11/3.12 × 3 packages -│ └── release.yml # Tag-triggered PyPI publish (trusted publishing) -├── pyproject.toml # Workspace root (ruff, pytest config) -├── .gitignore -└── README.md -``` - ---- - -## Provider Interfaces (SOLID Core) - -All providers use `typing.Protocol` (structural subtyping — no inheritance required). - -### LLMProvider - -```python -class LLMProvider(Protocol): - async def invoke( - self, - system_prompt: str, - messages: list[LLMMessage], - *, - max_tokens: int = 4096, - temperature: float = 0.0, - ) -> LLMResponse: ... - -@dataclass(frozen=True) -class LLMMessage: - role: Literal["user", "assistant"] - content: str | list[dict] # text or multimodal blocks - -@dataclass(frozen=True) -class LLMResponse: - text: str - input_tokens: int - output_tokens: int - model: str -``` - -### VisionProvider - -```python -class VisionProvider(Protocol): - async def extract_structured( - self, - image_data: bytes, - extraction_type: str, - *, - custom_fields: list[str] | None = None, - language_hint: str | None = None, - ) -> VisionExtractionResult: ... - - def get_supported_types(self) -> list[str]: ... -``` - -### EmbeddingProvider - -```python -class EmbeddingProvider(Protocol): - async def embed(self, texts: list[str]) -> list[list[float]]: ... - - @property - def dimension(self) -> int: ... -``` - -### CacheProvider - -```python -class CacheProvider(Protocol): - async def get(self, key: str) -> dict | None: ... - async def put(self, key: str, data: dict, ttl_seconds: int | None = None) -> None: ... - async def delete(self, key: str) -> bool: ... -``` - -### SessionProvider - -```python -class SessionProvider(Protocol): - async def get(self, session_id: str) -> Session | None: ... - async def save(self, session: Session) -> None: ... - async def delete(self, session_id: str) -> bool: ... - -@dataclass -class Session: - session_id: str - context: dict - tool_history: list[dict] - created_at: str - updated_at: str - ttl: int | None = None -``` - -### TelemetryProvider - -```python -class TelemetryProvider(Protocol): - async def emit_metric( - self, name: str, value: float, unit: str = "Count", - dimensions: dict[str, str] | None = None, - ) -> None: ... - - async def emit_tool_invocation( - self, tool_name: str, success: bool, duration_ms: float, - ) -> None: ... -``` - -### TranscribeProvider - -```python -class TranscribeProvider(Protocol): - async def transcribe( - self, - audio_data: bytes, - *, - language: str | None = None, - enable_diarization: bool = False, - ) -> TranscriptionResult: ... - -@dataclass(frozen=True) -class TranscriptionResult: - text: str - segments: list[dict] # [{start, end, text, speaker?}] - language: str - confidence: float -``` - ---- - -## Key Design Decisions - -### 1. Protocol over ABC -- `typing.Protocol` = structural subtyping → implementations don't need to inherit -- Duck typing with static type checking — Pythonic and flexible -- Users can write a plain class that matches the method signatures - -### 2. tool_data_store uses CacheProvider (not DynamoDB directly) -```python -class ToolDataStore: - def __init__(self, cache: CacheProvider, prefix: str = "td_"): - self._cache = cache # Injected — could be DynamoDB, Redis, in-memory -``` - -### 3. Generated servers import from mcp_forge_core (published on PyPI) -```python -# Generated server.py -from mcp_forge_core.server_factory import create_mcp_app -from mcp_forge_core.config import MCPServerConfig -``` - -### 4. AWS is an optional extras install -```bash -pip install mcp-forge-core # Core only (pure Python) -pip install mcp-forge-core[aws] # Pulls in mcp-forge-aws -pip install mcp-forge-cli # CLI tool -``` - -### 5. CLI has no cloud assumptions -- `--output-dir` instead of hardcoded project root -- `--author` / `--email` instead of hardcoded values -- `--templates` for custom template directory -- RegistryTarget Protocol for pluggable registry (not hardcoded to servers.json) - ---- - -## Implementation Order - -### Phase 1: mcp-forge-core (foundation) -1. Provider Protocol definitions (all 7 interfaces) -2. config.py — MCPServerConfig (generic, env-var driven) -3. server_factory.py — create_mcp_app() (extracted from mcp-shared) -4. circuit_breaker.py, retry.py — pure Python (copy from mcp-shared) -5. models.py — Pydantic models (extracted, no AWS refs) -6. tool_data_store.py — rewired to use CacheProvider Protocol -7. similarity.py — pure math (copy from mcp-shared) -8. InMemoryCache, InMemorySession — built-in dev providers -9. Tests for all modules - -### Phase 2: mcp-forge-aws (provider implementations) -1. BedrockLLMProvider — implements LLMProvider -2. BedrockVisionProvider — implements VisionProvider -3. BedrockEmbeddingProvider — implements EmbeddingProvider -4. DynamoDBCacheProvider — implements CacheProvider -5. DynamoDBSessionProvider — implements SessionProvider -6. CloudWatchTelemetryProvider — implements TelemetryProvider -7. AWSTranscribeProvider — implements TranscribeProvider -8. Tests with moto mocks - -### Phase 3: mcp-forge-cli (scaffold tool) -1. validators.py — name rules (generic, no project assumptions) -2. scaffold.py — ScaffoldConfig + MCPServerScaffold with ChoiceLoader -3. registry.py — RegistryTarget Protocol + RegistryUpdater -4. orchestrator.py — ForgeOrchestrator (thin coordination) -5. cli.py — Click handlers (parse → orchestrate → print) -6. Standalone templates (import mcp_forge_core, not mcp-shared) -7. Tests: scaffold generation, validation, dry-run - -### Phase 4: Integration -1. Publish to TestPyPI -2. Verify `pip install mcp-forge-cli && mcp-forge new my-server` works from scratch -3. Verify `pip install mcp-forge-core[aws]` provides all providers - ---- - -## Cloud Migration Path - -With provider interfaces in place: - -```bash -# AWS (current) -pip install mcp-forge-core[aws] - -# GCP (future) -pip install mcp-forge-gcp # VertexAILLM, FirestoreCache, GCPSpeech - -# Azure (future) -pip install mcp-forge-azure # AzureOpenAILLM, CosmosDBCache, AzureSpeech - -# Self-hosted (future) -pip install mcp-forge-local # OllamaLLM, RedisCache, WhisperTranscribe -``` - -Each provider package just implements the Protocol — zero changes to core or CLI. diff --git a/packages/mcp-forge-aws/src/mcp_forge_aws/bedrock_llm.py b/packages/mcp-forge-aws/src/mcp_forge_aws/bedrock_llm.py index 229eb6c..8ef6445 100644 --- a/packages/mcp-forge-aws/src/mcp_forge_aws/bedrock_llm.py +++ b/packages/mcp-forge-aws/src/mcp_forge_aws/bedrock_llm.py @@ -70,15 +70,9 @@ async def invoke( Supports both text and multimodal content blocks in messages. """ bedrock_messages = [ - {"role": m.role, "content": m.content if isinstance(m.content, str) else m.content} - for m in messages + {"role": m.role, "content": m.content} for m in messages ] - # Convert plain string content to Messages API format - for msg in bedrock_messages: - if isinstance(msg["content"], str): - msg["content"] = msg["content"] - body = { "anthropic_version": "bedrock-2023-05-31", "max_tokens": max_tokens, diff --git a/packages/mcp-forge-aws/src/mcp_forge_aws/cloudwatch.py b/packages/mcp-forge-aws/src/mcp_forge_aws/cloudwatch.py index 981f1b6..0d2aeb5 100644 --- a/packages/mcp-forge-aws/src/mcp_forge_aws/cloudwatch.py +++ b/packages/mcp-forge-aws/src/mcp_forge_aws/cloudwatch.py @@ -25,7 +25,7 @@ from typing import Any import boto3 -from botocore.exceptions import ClientError +from botocore.exceptions import BotoCoreError, ClientError from mcp_forge_core.providers.telemetry import BaseTelemetryProvider @@ -97,15 +97,12 @@ async def emit_metric( ) try: - loop = asyncio.get_running_loop() - await loop.run_in_executor( - None, - lambda: self._get_client().put_metric_data( - Namespace=self._namespace, - MetricData=[metric_data], - ), + await asyncio.to_thread( + self._get_client().put_metric_data, + Namespace=self._namespace, + MetricData=[metric_data], ) - except (ClientError, Exception) as e: + except (ClientError, BotoCoreError) as e: logger.warning("Failed to emit metric %s: %s", name, e) def __repr__(self) -> str: diff --git a/packages/mcp-forge-aws/src/mcp_forge_aws/dynamodb_cache.py b/packages/mcp-forge-aws/src/mcp_forge_aws/dynamodb_cache.py index 170add5..6db0092 100644 --- a/packages/mcp-forge-aws/src/mcp_forge_aws/dynamodb_cache.py +++ b/packages/mcp-forge-aws/src/mcp_forge_aws/dynamodb_cache.py @@ -23,6 +23,7 @@ from typing import Any import boto3 +from botocore.exceptions import BotoCoreError, ClientError from mcp_forge_core.providers.cache import BaseCacheProvider @@ -76,7 +77,7 @@ async def get(self, key: str) -> dict | None: ttl_val = int(item.get("ttl", {}).get("N", "0")) if ttl_val > time.time(): return json.loads(item[self._data_field]["S"]) - except Exception as e: + except (ClientError, BotoCoreError) as e: logger.warning("Cache get failed (non-fatal): %s", e) return None @@ -93,7 +94,7 @@ async def put(self, key: str, data: dict, ttl_seconds: int | None = None) -> Non "ttl": {"N": str(int(time.time()) + ttl)}, }, ) - except Exception as e: + except (ClientError, BotoCoreError) as e: logger.warning("Cache put failed (non-fatal): %s", e) async def delete(self, key: str) -> bool: @@ -106,7 +107,7 @@ async def delete(self, key: str) -> bool: ReturnValues="ALL_OLD", ) return "Attributes" in response - except Exception as e: + except (ClientError, BotoCoreError) as e: logger.warning("Cache delete failed (non-fatal): %s", e) return False diff --git a/packages/mcp-forge-aws/src/mcp_forge_aws/dynamodb_session.py b/packages/mcp-forge-aws/src/mcp_forge_aws/dynamodb_session.py index e060c9b..bd8afe2 100644 --- a/packages/mcp-forge-aws/src/mcp_forge_aws/dynamodb_session.py +++ b/packages/mcp-forge-aws/src/mcp_forge_aws/dynamodb_session.py @@ -21,6 +21,7 @@ from typing import Any import boto3 +from botocore.exceptions import BotoCoreError, ClientError from mcp_forge_core.providers.session import BaseSessionProvider, Session @@ -79,7 +80,7 @@ async def get(self, session_id: str) -> Session | None: updated_at=item.get("updated_at", {}).get("S", ""), ttl=ttl_val, ) - except Exception as e: + except (ClientError, BotoCoreError) as e: logger.warning("Session get failed: %s", e) return None @@ -101,7 +102,7 @@ async def save(self, session: Session) -> None: "ttl": {"N": str(int(time.time()) + self._ttl_seconds)}, }, ) - except Exception as e: + except (ClientError, BotoCoreError) as e: logger.warning("Session save failed: %s", e) async def delete(self, session_id: str) -> bool: @@ -114,7 +115,7 @@ async def delete(self, session_id: str) -> bool: ReturnValues="ALL_OLD", ) return "Attributes" in response - except Exception as e: + except (ClientError, BotoCoreError) as e: logger.warning("Session delete failed: %s", e) return False diff --git a/packages/mcp-forge-cli/src/mcp_forge_cli/cli.py b/packages/mcp-forge-cli/src/mcp_forge_cli/cli.py index fddd6d7..26f73ff 100644 --- a/packages/mcp-forge-cli/src/mcp_forge_cli/cli.py +++ b/packages/mcp-forge-cli/src/mcp_forge_cli/cli.py @@ -65,6 +65,7 @@ def new( def version_cmd() -> None: """Show mcp-forge version.""" from mcp_forge_core import __version__ as core_version + from mcp_forge_cli import __version__ as cli_version click.echo(f"mcp-forge-core: {core_version}") - click.echo(f"mcp-forge-cli: {ScaffoldConfig.__module__.split('.')[0]}") + click.echo(f"mcp-forge-cli: {cli_version}") diff --git a/packages/mcp-forge-cli/src/mcp_forge_cli/orchestrator.py b/packages/mcp-forge-cli/src/mcp_forge_cli/orchestrator.py index d6ff6b4..a33d37d 100644 --- a/packages/mcp-forge-cli/src/mcp_forge_cli/orchestrator.py +++ b/packages/mcp-forge-cli/src/mcp_forge_cli/orchestrator.py @@ -11,7 +11,7 @@ from .registry import BaseRegistryTarget, NoOpRegistry from .scaffold import MCPServerScaffold, ScaffoldConfig -from .validators import validate_output_dir, validate_server_name +from .validators import validate_output_dir, validate_server_name, validate_text_field logger = logging.getLogger(__name__) @@ -48,6 +48,15 @@ def create_server(self, config: ScaffoldConfig) -> Path: if dir_error: raise ValueError(dir_error) + for field_name, value in [ + ("author", config.author), + ("email", config.email), + ("description", config.description), + ]: + text_error = validate_text_field(value, field_name) + if text_error: + raise ValueError(text_error) + # 2. Scaffold scaffold = MCPServerScaffold(config) server_dir = scaffold.generate() diff --git a/packages/mcp-forge-cli/src/mcp_forge_cli/scaffold.py b/packages/mcp-forge-cli/src/mcp_forge_cli/scaffold.py index cf17f52..325c2fa 100644 --- a/packages/mcp-forge-cli/src/mcp_forge_cli/scaffold.py +++ b/packages/mcp-forge-cli/src/mcp_forge_cli/scaffold.py @@ -11,7 +11,8 @@ from pathlib import Path from typing import Any -from jinja2 import ChoiceLoader, Environment, FileSystemLoader +from jinja2 import ChoiceLoader, FileSystemLoader +from jinja2.sandbox import SandboxedEnvironment logger = logging.getLogger(__name__) @@ -54,8 +55,8 @@ def __init__(self, config: ScaffoldConfig) -> None: self._config = config self._env = self._create_env() - def _create_env(self) -> Environment: - """Build Jinja2 environment with optional custom template directory.""" + def _create_env(self) -> SandboxedEnvironment: + """Build sandboxed Jinja2 environment with optional custom template directory.""" loaders = [] if self._config.custom_templates_dir: @@ -63,7 +64,7 @@ def _create_env(self) -> Environment: loaders.append(FileSystemLoader(str(_TEMPLATES_DIR))) - return Environment( + return SandboxedEnvironment( loader=ChoiceLoader(loaders), keep_trailing_newline=True, trim_blocks=True, @@ -77,7 +78,12 @@ def generate(self) -> Path: Path to the created server directory. """ cfg = self._config - server_dir = Path(cfg.output_dir) / cfg.server_name + server_dir = (Path(cfg.output_dir) / cfg.server_name).resolve() + parent_dir = Path(cfg.output_dir).resolve() + if not str(server_dir).startswith(str(parent_dir)): + raise ValueError( + f"Server directory {server_dir} escapes output directory {parent_dir}" + ) pkg_name = cfg.server_name.replace("-", "_") context = self._build_context(cfg, pkg_name) diff --git a/packages/mcp-forge-cli/src/mcp_forge_cli/templates/pyproject.toml.j2 b/packages/mcp-forge-cli/src/mcp_forge_cli/templates/pyproject.toml.j2 index ebff1a6..199218f 100644 --- a/packages/mcp-forge-cli/src/mcp_forge_cli/templates/pyproject.toml.j2 +++ b/packages/mcp-forge-cli/src/mcp_forge_cli/templates/pyproject.toml.j2 @@ -12,7 +12,6 @@ authors = [{ name = "{{ author }}"{% if email %}, email = "{{ email }}"{% endif {% endif %} dependencies = [ "mcp-forge-core>=0.1.0", - "mcp>=1.0,<2.0", "uvicorn[standard]>=0.27.0", {% for dep in extra_deps %} "{{ dep }}", diff --git a/packages/mcp-forge-cli/src/mcp_forge_cli/templates/test_sample.py.j2 b/packages/mcp-forge-cli/src/mcp_forge_cli/templates/test_sample.py.j2 index 34af148..3db079d 100644 --- a/packages/mcp-forge-cli/src/mcp_forge_cli/templates/test_sample.py.j2 +++ b/packages/mcp-forge-cli/src/mcp_forge_cli/templates/test_sample.py.j2 @@ -2,7 +2,7 @@ from __future__ import annotations -from mcp.server.fastmcp import FastMCP +from mcp_forge_core import create_mcp_app class TestSampleTools: @@ -10,5 +10,5 @@ class TestSampleTools: """Verify tools register without errors.""" from {{ pkg_name }}.tools import register_tools - mcp = FastMCP("test") + mcp = create_mcp_app("test", "Test server") register_tools(mcp) diff --git a/packages/mcp-forge-cli/src/mcp_forge_cli/validators.py b/packages/mcp-forge-cli/src/mcp_forge_cli/validators.py index ab5aad9..a23012b 100644 --- a/packages/mcp-forge-cli/src/mcp_forge_cli/validators.py +++ b/packages/mcp-forge-cli/src/mcp_forge_cli/validators.py @@ -49,6 +49,30 @@ def validate_server_name(name: str) -> str | None: return None +def validate_text_field(value: str, field_name: str) -> str | None: + """Validate a text field that will be interpolated into generated files. + + Rejects characters that could break Python string literals or TOML values. + + Args: + value: The value to validate. + field_name: Field name for error messages. + + Returns: + Error message string if invalid, None if valid. + """ + if not value: + return None + + dangerous = {'"', "\n", "\r", "\\"} + found = [ch for ch in value if ch in dangerous] + if found: + chars = ", ".join(repr(ch) for ch in set(found)) + return f"{field_name} contains unsafe characters: {chars}" + + return None + + def validate_output_dir(path: str, server_name: str) -> str | None: """Check if the output directory would conflict with existing files. diff --git a/packages/mcp-forge-cli/tests/test_scaffold.py b/packages/mcp-forge-cli/tests/test_scaffold.py index 542c462..6eb003d 100644 --- a/packages/mcp-forge-cli/tests/test_scaffold.py +++ b/packages/mcp-forge-cli/tests/test_scaffold.py @@ -50,7 +50,7 @@ def test_pyproject_contains_metadata(self, tmp_path): ) MCPServerScaffold(config).generate() - content = (tmp_path / "demo-mcp" / "pyproject.toml").read_text() + content = (tmp_path / "demo-mcp" / "pyproject.toml").read_text(encoding="utf-8") assert 'name = "demo-mcp"' in content assert "Alice" in content assert "alice@example.com" in content @@ -60,7 +60,7 @@ def test_server_py_uses_mcp_forge(self, tmp_path): config = ScaffoldConfig(server_name="api-mcp", output_dir=str(tmp_path)) MCPServerScaffold(config).generate() - content = (tmp_path / "api-mcp" / "src" / "api_mcp" / "server.py").read_text() + content = (tmp_path / "api-mcp" / "src" / "api_mcp" / "server.py").read_text(encoding="utf-8") assert "from mcp_forge_core import create_mcp_app" in content assert "from mcp_forge_core import" in content @@ -68,7 +68,7 @@ def test_config_extends_mcp_server_config(self, tmp_path): config = ScaffoldConfig(server_name="cfg-mcp", output_dir=str(tmp_path)) MCPServerScaffold(config).generate() - content = (tmp_path / "cfg-mcp" / "src" / "cfg_mcp" / "config.py").read_text() + content = (tmp_path / "cfg-mcp" / "src" / "cfg_mcp" / "config.py").read_text(encoding="utf-8") assert "MCPServerConfig" in content assert "cfg-mcp" in content @@ -85,7 +85,7 @@ def test_generated_python_is_valid_syntax(self, tmp_path): assert len(py_files) > 0 for py_file in py_files: - source = py_file.read_text() + source = py_file.read_text(encoding="utf-8") try: ast.parse(source) except SyntaxError as e: @@ -99,7 +99,7 @@ def test_extra_deps_in_pyproject(self, tmp_path): ) MCPServerScaffold(config).generate() - content = (tmp_path / "deps-mcp" / "pyproject.toml").read_text() + content = (tmp_path / "deps-mcp" / "pyproject.toml").read_text(encoding="utf-8") assert "httpx>=0.27" in content assert "beautifulsoup4>=4.12" in content @@ -116,7 +116,7 @@ def test_custom_templates_override(self, tmp_path): ) MCPServerScaffold(config).generate() - content = (tmp_path / "custom-mcp" / "src" / "custom_mcp" / "server.py").read_text() + content = (tmp_path / "custom-mcp" / "src" / "custom_mcp" / "server.py").read_text(encoding="utf-8") assert "Custom server" in content def test_hyphenated_name_to_package(self, tmp_path): diff --git a/packages/mcp-forge-core/README.md b/packages/mcp-forge-core/README.md index 6c2c6fa..fc787e9 100644 --- a/packages/mcp-forge-core/README.md +++ b/packages/mcp-forge-core/README.md @@ -11,11 +11,11 @@ pip install mcp-forge-core ## Quick Start ```python -from mcp_forge_core import create_mcp_app +from mcp_forge_core import create_mcp_app, run_server from mcp_forge_core.providers import InMemoryCache mcp = create_mcp_app("my-server", "A helpful MCP server") -mcp.run() +run_server(mcp) ``` -See the [mcp-forge repository](https://github.com/mcp-forge/mcp-forge) for full details. +See the [mcp-forge repository](https://github.com/CoreNovus/mcp-forge) for full details. diff --git a/packages/mcp-forge-core/src/mcp_forge_core/__init__.py b/packages/mcp-forge-core/src/mcp_forge_core/__init__.py index 3a2d690..4c41a65 100644 --- a/packages/mcp-forge-core/src/mcp_forge_core/__init__.py +++ b/packages/mcp-forge-core/src/mcp_forge_core/__init__.py @@ -34,6 +34,7 @@ async def hello(name: str) -> str: from mcp_forge_core.decorators import measured, cached_tool, compacted """ +from mcp.server.fastmcp import FastMCP from .config import MCPServerConfig, get_mcp_config from .server_factory import create_mcp_app, run_server, get_http_app from .circuit_breaker import CircuitBreaker, CircuitState, CircuitOpenError @@ -46,6 +47,8 @@ async def hello(name: str) -> str: __version__ = "0.1.0" __all__ = [ + # MCP + "FastMCP", # Config "MCPServerConfig", "get_mcp_config", diff --git a/packages/mcp-forge-core/src/mcp_forge_core/config.py b/packages/mcp-forge-core/src/mcp_forge_core/config.py index 064c690..f4902ce 100644 --- a/packages/mcp-forge-core/src/mcp_forge_core/config.py +++ b/packages/mcp-forge-core/src/mcp_forge_core/config.py @@ -58,7 +58,7 @@ class AWSConfig(MCPServerConfig): ) # ── Network ────────────────────────────────────────────────────── - server_host: str = Field(default="0.0.0.0", description="HTTP server bind host") + server_host: str = Field(default="127.0.0.1", description="HTTP server bind host") server_port: int = Field(default=8000, description="HTTP server port") # ── Session ────────────────────────────────────────────────────── diff --git a/packages/mcp-forge-core/src/mcp_forge_core/providers/in_memory.py b/packages/mcp-forge-core/src/mcp_forge_core/providers/in_memory.py index 2212aad..d7a44aa 100644 --- a/packages/mcp-forge-core/src/mcp_forge_core/providers/in_memory.py +++ b/packages/mcp-forge-core/src/mcp_forge_core/providers/in_memory.py @@ -30,12 +30,17 @@ class InMemoryCache(BaseCacheProvider): """Dict-based cache for local development and testing. - Supports TTL via expiry timestamps. Not thread-safe — suitable for - single-process dev servers and unit tests. + Supports TTL via expiry timestamps and optional max size with + oldest-first eviction. Not thread-safe — suitable for single-process + dev servers and unit tests. + + Args: + max_size: Maximum number of entries. 0 means unlimited. """ - def __init__(self) -> None: + def __init__(self, max_size: int = 0) -> None: self._store: dict[str, tuple[Any, float | None]] = {} + self._max_size = max_size async def get(self, key: str) -> Any | None: entry = self._store.get(key) @@ -50,6 +55,9 @@ async def get(self, key: str) -> Any | None: async def put(self, key: str, data: Any, ttl_seconds: int | None = None) -> None: expires_at = time.time() + ttl_seconds if ttl_seconds else None self._store[key] = (data, expires_at) + if self._max_size > 0 and len(self._store) > self._max_size: + oldest_key = next(iter(self._store)) + del self._store[oldest_key] async def delete(self, key: str) -> bool: return self._store.pop(key, None) is not None @@ -91,10 +99,15 @@ class InMemoryTelemetry(BaseTelemetryProvider): Useful for local development (metrics appear in logs) and testing (inspect ``metrics`` list to verify tool invocations). + + Args: + max_metrics: Maximum stored metrics. Oldest are dropped when exceeded. + 0 means unlimited. """ - def __init__(self) -> None: + def __init__(self, max_metrics: int = 0) -> None: self.metrics: list[dict] = [] + self._max_metrics = max_metrics async def emit_metric( self, @@ -111,6 +124,8 @@ async def emit_metric( "timestamp": datetime.now(timezone.utc).isoformat(), } self.metrics.append(record) + if self._max_metrics > 0 and len(self.metrics) > self._max_metrics: + self.metrics = self.metrics[-self._max_metrics:] logger.debug("metric: %s=%s %s %s", name, value, unit, dimensions or "") def clear(self) -> None: diff --git a/packages/mcp-forge-core/src/mcp_forge_core/retry.py b/packages/mcp-forge-core/src/mcp_forge_core/retry.py index 24d6836..bfaa351 100644 --- a/packages/mcp-forge-core/src/mcp_forge_core/retry.py +++ b/packages/mcp-forge-core/src/mcp_forge_core/retry.py @@ -59,6 +59,33 @@ def _calculate_delay(attempt: int, config: RetryConfig) -> float: return delay +async def _execute_with_retry( + fn: Callable[..., Any], + args: tuple[Any, ...], + kwargs: dict[str, Any], + config: RetryConfig, +) -> Any: + """Core retry loop shared by the decorator and with_retry().""" + last_exception: Exception | None = None + for attempt in range(config.max_attempts): + try: + return await fn(*args, **kwargs) + except config.retryable_exceptions as exc: + last_exception = exc + if attempt < config.max_attempts - 1: + delay = _calculate_delay(attempt, config) + logger.warning( + "Retry %d/%d for %s after %.2fs: %s", + attempt + 1, + config.max_attempts, + getattr(fn, "__name__", str(fn)), + delay, + type(exc).__name__, + ) + await asyncio.sleep(delay) + raise last_exception # type: ignore[misc] + + def retry( func: F | None = None, *, @@ -106,24 +133,7 @@ async def from_config(): def decorator(fn: F) -> F: @functools.wraps(fn) async def wrapper(*args: Any, **kwargs: Any) -> Any: - last_exception: Exception | None = None - for attempt in range(config.max_attempts): - try: - return await fn(*args, **kwargs) - except config.retryable_exceptions as exc: - last_exception = exc - if attempt < config.max_attempts - 1: - delay = _calculate_delay(attempt, config) - logger.warning( - "Retry %d/%d for %s after %.2fs: %s", - attempt + 1, - config.max_attempts, - fn.__name__, - delay, - exc, - ) - await asyncio.sleep(delay) - raise last_exception # type: ignore[misc] + return await _execute_with_retry(fn, args, kwargs, config) return wrapper # type: ignore[return-value] @@ -153,23 +163,4 @@ async def with_retry( Returns: The return value of fn. """ - cfg = config or RetryConfig() - - last_exception: Exception | None = None - for attempt in range(cfg.max_attempts): - try: - return await fn(*args, **kwargs) - except cfg.retryable_exceptions as exc: - last_exception = exc - if attempt < cfg.max_attempts - 1: - delay = _calculate_delay(attempt, cfg) - logger.warning( - "Retry %d/%d for %s after %.2fs: %s", - attempt + 1, - cfg.max_attempts, - fn.__name__, - delay, - exc, - ) - await asyncio.sleep(delay) - raise last_exception # type: ignore[misc] + return await _execute_with_retry(fn, args, kwargs, config or RetryConfig()) diff --git a/packages/mcp-forge-core/src/mcp_forge_core/server_factory.py b/packages/mcp-forge-core/src/mcp_forge_core/server_factory.py index 18533e3..f37d427 100644 --- a/packages/mcp-forge-core/src/mcp_forge_core/server_factory.py +++ b/packages/mcp-forge-core/src/mcp_forge_core/server_factory.py @@ -117,7 +117,7 @@ def run_server( logger.info("Starting %s in stdio mode", mcp.name) mcp.run(transport="stdio") else: - actual_host = host or os.environ.get("MCP_SERVER_HOST", "0.0.0.0") + actual_host = host or os.environ.get("MCP_SERVER_HOST", "127.0.0.1") actual_port = port or int(os.environ.get("MCP_SERVER_PORT", "8000")) logger.info("Starting %s on %s:%d (stateless=%s)", mcp.name, actual_host, actual_port, stateless)