From 8173bed13fdd1e715f15c7e06da87f4be4d78a5d Mon Sep 17 00:00:00 2001 From: jiao Date: Sat, 11 Apr 2026 01:02:28 +0800 Subject: [PATCH 1/8] docs: fix broken repo link in core README and remove stale PLAN.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix mcp-forge-core README: github.com/mcp-forge/mcp-forge (404) → github.com/CoreNovus/mcp-forge - Fix Quick Start example: mcp.run() → run_server(mcp) to use the mode-switching server factory - Remove PLAN.md: documented Protocol-based architecture but code uses ABCs, template filenames and workflow structure were outdated Co-Authored-By: Claude Opus 4.6 (1M context) --- PLAN.md | 285 ------------------------------ packages/mcp-forge-core/README.md | 6 +- 2 files changed, 3 insertions(+), 288 deletions(-) delete mode 100644 PLAN.md 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-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. From 9deb021baefa1b39370e0022365e5655eb1d8abd Mon Sep 17 00:00:00 2001 From: jiao Date: Sat, 11 Apr 2026 01:03:28 +0800 Subject: [PATCH 2/8] security: harden scaffold, providers, and AWS error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffold (mcp-forge-cli): - Use SandboxedEnvironment instead of Environment to prevent Jinja2 template introspection attacks via custom --templates directories - Add path traversal guard in MCPServerScaffold.generate() — verify output stays within the intended parent directory - Add validate_text_field() to reject unsafe characters (", \n, \r, \) in author/email/description fields before template interpolation - Call validation in orchestrator before scaffolding Providers (mcp-forge-core): - InMemoryCache: add max_size parameter with oldest-first eviction to prevent unbounded memory growth in long-running dev servers - InMemoryTelemetry: add max_metrics parameter with rolling window - Default server_host from 0.0.0.0 to 127.0.0.1 in config.py and server_factory.py to prevent unintended network exposure in dev AWS providers (mcp-forge-aws): - Narrow exception handling: catch (ClientError, BotoCoreError) instead of bare Exception in DynamoDB and CloudWatch providers — let programming errors propagate instead of being silently swallowed - CloudWatch: use asyncio.to_thread() for consistency with DynamoDB providers instead of loop.run_in_executor(lambda) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/mcp_forge_aws/cloudwatch.py | 15 +++++------- .../src/mcp_forge_aws/dynamodb_cache.py | 7 +++--- .../src/mcp_forge_aws/dynamodb_session.py | 7 +++--- .../src/mcp_forge_cli/orchestrator.py | 11 ++++++++- .../src/mcp_forge_cli/scaffold.py | 16 +++++++++---- .../src/mcp_forge_cli/validators.py | 24 +++++++++++++++++++ .../src/mcp_forge_core/config.py | 2 +- .../src/mcp_forge_core/providers/in_memory.py | 23 ++++++++++++++---- .../src/mcp_forge_core/server_factory.py | 2 +- 9 files changed, 80 insertions(+), 27 deletions(-) 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/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/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-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/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) From 3f31cb5a7ebfa12ff171fe3323bc8320c80ab4a6 Mon Sep 17 00:00:00 2001 From: jiao Date: Sat, 11 Apr 2026 01:04:22 +0800 Subject: [PATCH 3/8] refactor: clean up dead code, DRY retry, and fix encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bedrock_llm.py: remove no-op message conversion loop — the ternary and subsequent for-loop were both identity operations - retry.py: extract _execute_with_retry() to eliminate duplicated retry loop between @retry decorator and with_retry() function; log type(exc).__name__ instead of full exception message to avoid leaking sensitive data in retry warnings - cli.py: fix version command — use proper __version__ import instead of hacky ScaffoldConfig.__module__.split('.')[0] - test_scaffold.py: add encoding="utf-8" to all read_text() calls — fixes 2 pre-existing test failures on Windows cp950 locale caused by em-dash characters in generated server.py docstring Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/mcp_forge_aws/bedrock_llm.py | 8 +-- .../mcp-forge-cli/src/mcp_forge_cli/cli.py | 3 +- packages/mcp-forge-cli/tests/test_scaffold.py | 12 ++-- .../src/mcp_forge_core/retry.py | 67 ++++++++----------- 4 files changed, 38 insertions(+), 52 deletions(-) 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-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/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/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()) From f8c292fb30c3c72888c5271bb07621d8ac52c060 Mon Sep 17 00:00:00 2001 From: jiao Date: Sat, 11 Apr 2026 01:05:38 +0800 Subject: [PATCH 4/8] feat: re-export FastMCP and hide mcp SDK from generated servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the mcp SDK an invisible implementation detail of mcp-forge-core so that generated servers never import from mcp.server.fastmcp directly. - Re-export FastMCP from mcp_forge_core.__init__ — users can now write from mcp_forge_core import FastMCP for type annotations - Update test_sample.py.j2 to use create_mcp_app() instead of directly importing FastMCP from the mcp SDK - Remove redundant mcp>=1.0 dependency from pyproject.toml.j2 — it is already a transitive dependency via mcp-forge-core When mcp SDK 2.0 ships, only server_factory.py needs updating — no generated user code will break. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/mcp_forge_cli/templates/pyproject.toml.j2 | 1 - .../src/mcp_forge_cli/templates/test_sample.py.j2 | 4 ++-- packages/mcp-forge-core/src/mcp_forge_core/__init__.py | 3 +++ 3 files changed, 5 insertions(+), 3 deletions(-) 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-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", From db0270ee25cd7ac93a3375c9f864a7a68426c9f4 Mon Sep 17 00:00:00 2001 From: jiao Date: Wed, 15 Apr 2026 08:18:08 +0800 Subject: [PATCH 5/8] docs(examples): add multi-server scenario and top-level index Adds examples/README.md as the scenario picker and examples/multi-server/ with a step-by-step walkthrough, docker-compose.yml for three scaffolded servers on 8001/8002/8003, a shared Dockerfile, and an .env.example for the common MCP_* knobs. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/README.md | 49 +++++ examples/multi-server/.env.example | 33 ++++ examples/multi-server/Dockerfile | 26 +++ examples/multi-server/README.md | 218 +++++++++++++++++++++++ examples/multi-server/docker-compose.yml | 67 +++++++ 5 files changed, 393 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/multi-server/.env.example create mode 100644 examples/multi-server/Dockerfile create mode 100644 examples/multi-server/README.md create mode 100644 examples/multi-server/docker-compose.yml diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..8c26820 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,49 @@ +# mcp-forge examples + +Two scenarios, two folders. Pick the one that matches what you want to do. + +## Mental model + +`mcp-forge` is a **server-side framework**. It scaffolds and runs MCP servers — it +does **not** give you an agent. Your agent is a separate process that connects +to the server(s) over a transport (HTTP or stdio). + +``` +┌────────────┐ HTTP (default) ┌────────────────┐ ┌─────────────┐ +│ Agent │ ─────────────────────▶ │ MCP server │ ──▶ │ Providers │ +│ (you own) │ or stdio (desktop) │ (mcp-forge) │ │ (InMemory / │ +│ │ │ = FastMCP │ │ AWS / ...) │ +└────────────┘ └────────────────┘ └─────────────┘ +``` + +- `create_mcp_app()` returns a `FastMCP` instance and `run_server()` starts it. + HTTP is default; set `MCP_SERVER_MODE=stdio` for Claude Desktop and similar. + See [`packages/mcp-forge-core/src/mcp_forge_core/server_factory.py`](../packages/mcp-forge-core/src/mcp_forge_core/server_factory.py). +- The CLI (`mcp-forge new -mcp`) scaffolds a working server from templates. + See [`packages/mcp-forge-cli`](../packages/mcp-forge-cli). + +## Which example? + +| Folder | When to use | +|--------|-------------| +| [`multi-server/`](./multi-server/) | You just need several MCP servers running locally (or in containers). No agent yet. | +| [`agent-integration/`](./agent-integration/) | You already have (or want to build) an agent that talks to those servers. | + +Most users do both: start with `multi-server/` to get servers up, then move to +`agent-integration/` to wire them into their agent. + +## Prerequisites + +- Python 3.11+ +- `pip install mcp-forge-cli` (pulls in `mcp-forge-core`) +- Docker (optional — only needed for the `docker-compose.yml` in `multi-server/`) +- AWS credentials (optional — only if you swap in the DynamoDB / CloudWatch / Bedrock providers) + +## Notes on the code you'll see + +- **Server-side** code (scaffolded projects, provider wiring, tool functions) + is real and runs. Every command can be executed verbatim. +- **Agent-side** code (`agent.py` in `anthropic-sdk/` and `langchain-langgraph/`) + is **pseudo-code**: the import paths and APIs of the `mcp` client library and + agent frameworks move fast. Use these files as a blueprint, then adjust to + the versions you install. diff --git a/examples/multi-server/.env.example b/examples/multi-server/.env.example new file mode 100644 index 0000000..b4d028e --- /dev/null +++ b/examples/multi-server/.env.example @@ -0,0 +1,33 @@ +# Copy to .env, then edit. mcp-forge-core reads these via pydantic BaseSettings +# with env_prefix="MCP_". Full list: packages/mcp-forge-core/src/mcp_forge_core/config.py + +# ---- Server runtime ---- +MCP_SERVER_MODE=http # http (default) | stdio +MCP_SERVER_HOST=127.0.0.1 +MCP_SERVER_PORT=8000 +MCP_LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR +MCP_ENVIRONMENT=development # development | staging | production + +# ---- Sessions ---- +MCP_SESSION_TTL_HOURS=24 + +# ---- Resilience ---- +MCP_CIRCUIT_BREAKER_FAILURE_THRESHOLD=5 +MCP_CIRCUIT_BREAKER_RECOVERY_TIMEOUT=30 +MCP_RETRY_MAX_ATTEMPTS=3 +MCP_RETRY_BASE_DELAY=1.0 +MCP_RETRY_MAX_DELAY=30.0 + +# ---- Telemetry ---- +MCP_METRICS_NAMESPACE=mcp-forge + +# ---- AWS (only if using mcp-forge-aws providers) ---- +# MCP_AWS_REGION=us-east-1 +# MCP_AWS_ENDPOINT_URL= # set to http://localhost:4566 for LocalStack +# MCP_SESSIONS_TABLE=mcp-sessions +# MCP_TOOL_DATA_TABLE=mcp-tool-data +# MCP_BEDROCK_MODEL_ID=us.anthropic.claude-sonnet-4-6-20250514-v1:0 +# MCP_BEDROCK_EMBEDDING_MODEL_ID=amazon.titan-embed-text-v2:0 +# MCP_EMBEDDING_DIMENSIONS=1024 +# MCP_BEDROCK_VISION_MODEL_ID=anthropic.claude-sonnet-4-6-20250514-v1:0 +# MCP_TRANSCRIBE_OUTPUT_BUCKET= diff --git a/examples/multi-server/Dockerfile b/examples/multi-server/Dockerfile new file mode 100644 index 0000000..bc42b52 --- /dev/null +++ b/examples/multi-server/Dockerfile @@ -0,0 +1,26 @@ +# Shared Dockerfile for any mcp-forge-scaffolded server. +# +# Build context: one of the scaffolded server directories (e.g. ./search-mcp/). +# Build arg: PKG_NAME = the underscored package name (e.g. search_mcp). +# +# Usage (from examples/multi-server/): +# docker build -f Dockerfile --build-arg PKG_NAME=search_mcp ./search-mcp + +FROM python:3.11-slim + +ARG PKG_NAME +ENV PKG_NAME=${PKG_NAME} + +WORKDIR /app + +# Install the scaffolded server package. The generated pyproject.toml declares +# mcp-forge-core as a dependency, which pip will resolve from PyPI (or from a +# local wheel if you mount one — left as an exercise). +COPY pyproject.toml ./ +COPY src ./src +RUN pip install --no-cache-dir . + +EXPOSE 8000 + +# run_server() reads MCP_SERVER_MODE / MCP_SERVER_HOST / MCP_SERVER_PORT from env. +CMD python -m ${PKG_NAME}.server diff --git a/examples/multi-server/README.md b/examples/multi-server/README.md new file mode 100644 index 0000000..16c233b --- /dev/null +++ b/examples/multi-server/README.md @@ -0,0 +1,218 @@ +# Scenario A — Multiple MCP servers + +Goal: stand up three independent MCP servers (`search-mcp`, `knowledge-mcp`, +`metrics-mcp`) on ports 8001/8002/8003, using the `mcp-forge` CLI and the +`mcp-forge-core` framework. + +No agent yet — this folder is purely about building and running servers. + +--- + +## 1. Install the CLI + +```bash +pip install -e ../../packages/mcp-forge-cli +# or, once published: +# pip install mcp-forge-cli +``` + +Verify: + +```bash +mcp-forge version +``` + +--- + +## 2. Scaffold three servers + +Run from **this directory** (`examples/multi-server/`). Each command generates +a self-contained Python package next to this README. + +```bash +mcp-forge new search-mcp --description "Web search tools" -a "Your Name" -e you@example.com +mcp-forge new knowledge-mcp --description "Document QA tools" -a "Your Name" -e you@example.com +mcp-forge new metrics-mcp --description "Analytics tools" -a "Your Name" -e you@example.com +``` + +Name rules (enforced by the CLI — see [`cli.py`](../../packages/mcp-forge-cli/src/mcp_forge_cli/cli.py)): + +- Must match `^[a-z][a-z0-9]*(-[a-z0-9]+)*-mcp$` +- Lowercase, hyphen-separated +- Must end in `-mcp` + +Each scaffold produces: + +``` +search-mcp/ +├── pyproject.toml +├── src/search_mcp/ +│ ├── __init__.py +│ ├── config.py # extends MCPServerConfig (env_prefix="MCP_") +│ ├── server.py # create_mcp_app(...) + run_server(mcp) +│ └── tools/ +│ ├── __init__.py # aggregator: register_tools(mcp) +│ └── sample.py # starter tools: hello(), echo() +└── tests/ + ├── conftest.py + └── test_sample.py +``` + +Install each in editable mode so you can run them locally: + +```bash +pip install -e ./search-mcp -e ./knowledge-mcp -e ./metrics-mcp +``` + +--- + +## 3. Add your own tools + +Open `search-mcp/src/search_mcp/tools/sample.py`. You'll see the **closure +registration pattern** mcp-forge uses (see +[`server_factory.py`](../../packages/mcp-forge-core/src/mcp_forge_core/server_factory.py)): + +```python +def register_sample_tools(mcp) -> None: + @mcp.tool() + async def hello(name: str) -> str: + return f"Hello, {name}!" +``` + +Add a real tool the same way. For production-grade tools, wire a +[`ToolContext`](../../packages/mcp-forge-core/src/mcp_forge_core/tool_context.py) +with a cache + telemetry so you get caching and metrics with zero boilerplate: + +```python +# search-mcp/src/search_mcp/tools/search.py +from mcp_forge_core import ToolContext +from mcp_forge_core.providers import InMemoryCache, InMemoryTelemetry + +_ctx = ToolContext(cache=InMemoryCache(), telemetry=InMemoryTelemetry()) + +def register_search_tools(mcp) -> None: + @mcp.tool() + async def search(query: str) -> dict: + """Search the web.""" + async with _ctx.measured("search"): + return await _ctx.cached( + key=_ctx.hash_key(query), + fn=lambda: _do_search(query), + ttl_seconds=3600, + ) + +async def _do_search(query: str) -> dict: + # your real implementation + return {"query": query, "results": []} +``` + +Then add the new module to `src/search_mcp/tools/__init__.py` so it gets +registered alongside the sample tools. + +--- + +## 4. Swap InMemory providers for AWS (or anything else) + +The whole point of the provider ABCs is that tool code doesn't change when you +change backends. Same tool, different construction site: + +```python +# Dev +from mcp_forge_core.providers import InMemoryCache, InMemorySession, InMemoryTelemetry +ctx = ToolContext( + cache=InMemoryCache(), + session=InMemorySession(), + telemetry=InMemoryTelemetry(), +) + +# Prod +from mcp_forge_aws import ( + DynamoDBCacheProvider, + DynamoDBSessionProvider, + CloudWatchTelemetryProvider, +) +ctx = ToolContext( + cache=DynamoDBCacheProvider(table_name="search-cache"), + session=DynamoDBSessionProvider(table_name="search-sessions"), + telemetry=CloudWatchTelemetryProvider(namespace="MCP/Servers", server_name="search-mcp"), +) +``` + +Full AWS provider catalogue: +[`packages/mcp-forge-aws/src/mcp_forge_aws/__init__.py`](../../packages/mcp-forge-aws/src/mcp_forge_aws/__init__.py). + +--- + +## 5. Configuration via environment variables + +Both `MCPServerConfig` and `AWSConfig` are pydantic `BaseSettings` with +`env_prefix="MCP_"`. Any field becomes `MCP_`. Copy the template: + +```bash +cp .env.example .env +# edit .env to taste +``` + +Common knobs: + +| Env var | Meaning | Default | +|---|---|---| +| `MCP_SERVER_MODE` | `http` or `stdio` | `http` | +| `MCP_SERVER_HOST` | bind host | `127.0.0.1` | +| `MCP_SERVER_PORT` | bind port | `8000` | +| `MCP_LOG_LEVEL` | `DEBUG`/`INFO`/`WARNING`/`ERROR` | `INFO` | +| `MCP_AWS_REGION` | AWS region (if AWS providers used) | `us-east-1` | + +Full list: +[`config.py`](../../packages/mcp-forge-core/src/mcp_forge_core/config.py), +[`aws/config.py`](../../packages/mcp-forge-aws/src/mcp_forge_aws/config.py). + +--- + +## 6. Run all three servers + +### Option A — plain Python, three terminals + +```bash +# terminal 1 +MCP_SERVER_PORT=8001 python -m search_mcp.server + +# terminal 2 +MCP_SERVER_PORT=8002 python -m knowledge_mcp.server + +# terminal 3 +MCP_SERVER_PORT=8003 python -m metrics_mcp.server +``` + +### Option B — docker-compose (recommended for local + CI) + +```bash +docker-compose up --build +``` + +The compose file builds each scaffolded package into a container using the +shared `Dockerfile` and maps host ports 8001/8002/8003 → container port 8000. + +--- + +## 7. Sanity-check the servers + +MCP endpoints aren't REST; a raw `curl GET` typically returns 404/405. The +easiest check is to point an MCP client at the server (see +[`../agent-integration/`](../agent-integration/)). + +To verify just that the process is up: + +```bash +# socket-level check +nc -z localhost 8001 && echo "search-mcp up" +nc -z localhost 8002 && echo "knowledge-mcp up" +nc -z localhost 8003 && echo "metrics-mcp up" +``` + +--- + +## Next + +Go to [`../agent-integration/`](../agent-integration/) to connect an agent to +these servers. diff --git a/examples/multi-server/docker-compose.yml b/examples/multi-server/docker-compose.yml new file mode 100644 index 0000000..3051dcb --- /dev/null +++ b/examples/multi-server/docker-compose.yml @@ -0,0 +1,67 @@ +# docker-compose for the three scaffolded MCP servers. +# +# Prerequisite: run `mcp-forge new search-mcp`, `mcp-forge new knowledge-mcp`, +# and `mcp-forge new metrics-mcp` in this directory first, so the three source +# trees exist next to this file. +# +# Then: `docker-compose up --build` + +version: "3.9" + +services: + search-mcp: + build: + context: ./search-mcp + dockerfile: ../Dockerfile + args: + PKG_NAME: search_mcp + image: mcp-forge/search-mcp:dev + environment: + MCP_SERVER_MODE: http + MCP_SERVER_HOST: 0.0.0.0 + MCP_SERVER_PORT: "8000" + MCP_LOG_LEVEL: INFO + ports: + - "8001:8000" + + knowledge-mcp: + build: + context: ./knowledge-mcp + dockerfile: ../Dockerfile + args: + PKG_NAME: knowledge_mcp + image: mcp-forge/knowledge-mcp:dev + environment: + MCP_SERVER_MODE: http + MCP_SERVER_HOST: 0.0.0.0 + MCP_SERVER_PORT: "8000" + MCP_LOG_LEVEL: INFO + ports: + - "8002:8000" + + metrics-mcp: + build: + context: ./metrics-mcp + dockerfile: ../Dockerfile + args: + PKG_NAME: metrics_mcp + image: mcp-forge/metrics-mcp:dev + environment: + MCP_SERVER_MODE: http + MCP_SERVER_HOST: 0.0.0.0 + MCP_SERVER_PORT: "8000" + MCP_LOG_LEVEL: INFO + ports: + - "8003:8000" + + # Uncomment to run DynamoDB/CloudWatch-backed providers locally via LocalStack. + # You'll also need to set MCP_AWS_ENDPOINT_URL=http://localstack:4566 on the + # services above. + # + # localstack: + # image: localstack/localstack:latest + # environment: + # SERVICES: dynamodb,cloudwatch,s3,transcribe + # DEFAULT_REGION: us-east-1 + # ports: + # - "4566:4566" From b169af0415a76d08757458fde064f4dae5af48dd Mon Sep 17 00:00:00 2001 From: jiao Date: Wed, 15 Apr 2026 08:18:14 +0800 Subject: [PATCH 6/8] docs(examples): add agent-integration overview Introduces examples/agent-integration/ with the transport-choice explainer (http vs stdio), shared prerequisites, and a pointer to the two framework sub-folders that follow in subsequent commits. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/agent-integration/README.md | 50 ++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 examples/agent-integration/README.md diff --git a/examples/agent-integration/README.md b/examples/agent-integration/README.md new file mode 100644 index 0000000..756a6b5 --- /dev/null +++ b/examples/agent-integration/README.md @@ -0,0 +1,50 @@ +# Scenario B — Connect your agent to the servers + +You have servers (the three from [`../multi-server/`](../multi-server/)) running +on `localhost:8001`, `:8002`, `:8003`. Now drive them from an agent you own. + +## Transport choice + +`mcp-forge` servers support two transports, chosen via the `MCP_SERVER_MODE` +env var on the **server** side: + +| Mode | When | How the client connects | +|---|---|---| +| `http` (default) | Production, containers, anywhere the agent is a separate process. | HTTP POST / streamable-HTTP to `http://host:port/mcp` | +| `stdio` | Claude Desktop, local dev, subprocess-style embedding. | Client spawns the server as a subprocess and talks over stdin/stdout. | + +The rest of this folder assumes **HTTP** (it's the default and what +`docker-compose up` in `../multi-server/` uses). If you need stdio, see +[stdio note](#stdio-note) at the bottom. + +## Pick your framework + +| Folder | Best for | +|---|---| +| [`anthropic-sdk/`](./anthropic-sdk/) | You want a minimal, explicit agent loop with the official `anthropic` SDK + `mcp` Python client. Maximum control, smallest dependency set. | +| [`langchain-langgraph/`](./langchain-langgraph/) | You already use LangChain/LangGraph, or you want a pre-built ReAct agent with conditional edges, memory, etc. | + +Both folders connect to the same three servers. You can copy whichever one +matches your stack. + +## Prerequisites (both options) + +1. Servers running — `cd ../multi-server && docker-compose up` (or the three + terminals approach). +2. `ANTHROPIC_API_KEY` exported (both examples drive Claude). +3. Python 3.11+. + +## Pseudo-code warning + +The `agent.py` in each sub-folder is **pseudo-code**. The MCP Python client +library and the LangChain-MCP adapters are under active development — their +exact import paths and method signatures change between releases. Use the +files as a blueprint, then pin versions and adapt imports to what `pip` +actually installs for you. + +## stdio note + +If you point the server at stdio (`MCP_SERVER_MODE=stdio python -m search_mcp.server`), +replace `streamablehttp_client(url)` with `stdio_client(server_params)` on the +client side (the `mcp` package provides both). The rest of the agent code +doesn't change. From 62b5a1ded6b4e36095bd3c97a2bb2419ce2fa309 Mon Sep 17 00:00:00 2001 From: jiao Date: Wed, 15 Apr 2026 08:18:21 +0800 Subject: [PATCH 7/8] docs(examples): add Anthropic SDK agent blueprint Pseudo-code agent.py showing how to open an MCP ClientSession against each of the three servers, flatten tool manifests into Anthropic tool_use format with server-prefixed names, and run an agentic loop with tool routing. Includes README with step-by-step usage and a requirements.txt pinning the minimal deps. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agent-integration/anthropic-sdk/README.md | 44 ++++++ .../agent-integration/anthropic-sdk/agent.py | 141 ++++++++++++++++++ .../anthropic-sdk/requirements.txt | 2 + 3 files changed, 187 insertions(+) create mode 100644 examples/agent-integration/anthropic-sdk/README.md create mode 100644 examples/agent-integration/anthropic-sdk/agent.py create mode 100644 examples/agent-integration/anthropic-sdk/requirements.txt diff --git a/examples/agent-integration/anthropic-sdk/README.md b/examples/agent-integration/anthropic-sdk/README.md new file mode 100644 index 0000000..6cb1583 --- /dev/null +++ b/examples/agent-integration/anthropic-sdk/README.md @@ -0,0 +1,44 @@ +# Anthropic SDK agent → multiple MCP servers + +Minimal agent loop using the `anthropic` SDK for the model and the official +`mcp` Python client library to talk to the three servers. + +## Steps + +1. **Start the servers** — `cd ../../multi-server && docker-compose up`. +2. **Install deps**: + ```bash + pip install -r requirements.txt + ``` +3. **Export your key**: + ```bash + export ANTHROPIC_API_KEY=sk-ant-... + ``` +4. **Run**: + ```bash + python agent.py + ``` + +The script: +1. Opens a `ClientSession` against each of the three servers. +2. Calls `list_tools()` on each to discover what's available. +3. Flattens the tool manifests into the Anthropic `tool_use` format, prefixing + each tool name with its server (e.g. `search__web_search`) so the agent can + route calls back to the right session. +4. Runs an agentic loop: model → `tool_use` blocks → `session.call_tool()` → + `tool_result` → repeat until the model stops. + +## What's pseudo-code, what isn't + +- The **control flow** (connect → list → loop → call → feed back) is correct + and universally applicable. +- The **imports and method names** target a specific version of the `mcp` + package. If your installed version differs, the fix is usually just renaming + an import or swapping a context-manager style for a direct call. +- The **Anthropic SDK calls** (`client.messages.create(...)`) are stable and + unlikely to change. + +## Files + +- [`agent.py`](./agent.py) — the pseudo-code blueprint. +- [`requirements.txt`](./requirements.txt) — minimal deps. diff --git a/examples/agent-integration/anthropic-sdk/agent.py b/examples/agent-integration/anthropic-sdk/agent.py new file mode 100644 index 0000000..5cdb0ea --- /dev/null +++ b/examples/agent-integration/anthropic-sdk/agent.py @@ -0,0 +1,141 @@ +"""Anthropic SDK agent that talks to three mcp-forge servers over HTTP. + +PSEUDO-CODE. The `mcp` Python client library is under active development, so +exact import paths and context-manager conventions can drift between versions. +Treat the control flow as authoritative and adjust imports to the versions you +install. + +Run: + pip install -r requirements.txt + export ANTHROPIC_API_KEY=sk-ant-... + python agent.py +""" + +from __future__ import annotations + +import asyncio +import os +from contextlib import AsyncExitStack +from typing import Any + +import anthropic + +# The `mcp` package ships the client. Exact submodule path may vary by version. +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +SERVERS: dict[str, str] = { + "search": "http://localhost:8001/mcp", + "knowledge": "http://localhost:8002/mcp", + "metrics": "http://localhost:8003/mcp", +} + +MODEL = "claude-sonnet-4-5" +MAX_TURNS = 10 + + +async def connect_all(stack: AsyncExitStack) -> dict[str, ClientSession]: + """Open one MCP session per server. All sessions are cleaned up when `stack` exits.""" + sessions: dict[str, ClientSession] = {} + for name, url in SERVERS.items(): + read, write, _ = await stack.enter_async_context(streamablehttp_client(url)) + session = await stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + sessions[name] = session + return sessions + + +async def collect_tools( + sessions: dict[str, ClientSession], +) -> tuple[list[dict[str, Any]], dict[str, tuple[str, str]]]: + """Build the Anthropic-style tool list and a name→(server, original_name) routing table. + + We prefix tool names with `{server}__` so the agent can route a tool_use + block back to the correct MCP session even if two servers happen to expose + a tool with the same name. + """ + anthropic_tools: list[dict[str, Any]] = [] + routing: dict[str, tuple[str, str]] = {} + + for server_name, session in sessions.items(): + listing = await session.list_tools() + for tool in listing.tools: + wrapped_name = f"{server_name}__{tool.name}" + anthropic_tools.append({ + "name": wrapped_name, + "description": tool.description or "", + "input_schema": tool.inputSchema, + }) + routing[wrapped_name] = (server_name, tool.name) + + return anthropic_tools, routing + + +async def call_tool( + sessions: dict[str, ClientSession], + routing: dict[str, tuple[str, str]], + wrapped_name: str, + arguments: dict[str, Any], +) -> str: + """Route a tool call to the right session and return a string payload for the model.""" + server_name, original_name = routing[wrapped_name] + result = await sessions[server_name].call_tool(original_name, arguments) + + # MCP tool results are a list of content blocks (TextContent, ImageContent, ...). + # For simplicity we concatenate text blocks; extend as needed. + parts: list[str] = [] + for block in result.content: + text = getattr(block, "text", None) + if text: + parts.append(text) + return "\n".join(parts) if parts else "(no content)" + + +async def run_conversation(user_prompt: str) -> None: + if not os.getenv("ANTHROPIC_API_KEY"): + raise SystemExit("ANTHROPIC_API_KEY is not set") + + client = anthropic.Anthropic() + + async with AsyncExitStack() as stack: + sessions = await connect_all(stack) + tools, routing = await collect_tools(sessions) + + messages: list[dict[str, Any]] = [{"role": "user", "content": user_prompt}] + + for _ in range(MAX_TURNS): + response = client.messages.create( + model=MODEL, + max_tokens=4096, + tools=tools, + messages=messages, + ) + + messages.append({"role": "assistant", "content": response.content}) + + if response.stop_reason != "tool_use": + for block in response.content: + if getattr(block, "type", None) == "text": + print(block.text) + return + + # Fulfil each tool_use block and append the results in a single user turn. + tool_results: list[dict[str, Any]] = [] + for block in response.content: + if getattr(block, "type", None) != "tool_use": + continue + output = await call_tool(sessions, routing, block.name, block.input) + tool_results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": output, + }) + + messages.append({"role": "user", "content": tool_results}) + + print("Hit MAX_TURNS without a final answer.") + + +if __name__ == "__main__": + asyncio.run(run_conversation("Find recent news about MCP and summarise in 3 bullets.")) diff --git a/examples/agent-integration/anthropic-sdk/requirements.txt b/examples/agent-integration/anthropic-sdk/requirements.txt new file mode 100644 index 0000000..b9745f0 --- /dev/null +++ b/examples/agent-integration/anthropic-sdk/requirements.txt @@ -0,0 +1,2 @@ +anthropic>=0.40.0 +mcp>=1.0.0 From 3f8fdce5f2cd0b36804216e139534c31acdf360d Mon Sep 17 00:00:00 2001 From: jiao Date: Wed, 15 Apr 2026 08:18:28 +0800 Subject: [PATCH 8/8] docs(examples): add LangChain/LangGraph agent blueprint Pseudo-code agent.py using langchain-mcp-adapters' MultiServerMCPClient to aggregate tools from the three servers into a LangGraph prebuilt ReAct agent. Includes README explaining when to pick this over the raw Anthropic SDK approach and a requirements.txt with the LangChain stack. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../langchain-langgraph/README.md | 46 +++++++++++++ .../langchain-langgraph/agent.py | 66 +++++++++++++++++++ .../langchain-langgraph/requirements.txt | 4 ++ 3 files changed, 116 insertions(+) create mode 100644 examples/agent-integration/langchain-langgraph/README.md create mode 100644 examples/agent-integration/langchain-langgraph/agent.py create mode 100644 examples/agent-integration/langchain-langgraph/requirements.txt diff --git a/examples/agent-integration/langchain-langgraph/README.md b/examples/agent-integration/langchain-langgraph/README.md new file mode 100644 index 0000000..670eecd --- /dev/null +++ b/examples/agent-integration/langchain-langgraph/README.md @@ -0,0 +1,46 @@ +# LangChain / LangGraph agent → multiple MCP servers + +Pre-built ReAct agent from LangGraph, with tools sourced from your three +`mcp-forge` servers via `langchain-mcp-adapters`. + +## Steps + +1. **Start the servers** — `cd ../../multi-server && docker-compose up`. +2. **Install deps**: + ```bash + pip install -r requirements.txt + ``` +3. **Export your key**: + ```bash + export ANTHROPIC_API_KEY=sk-ant-... + ``` +4. **Run**: + ```bash + python agent.py + ``` + +## How it works + +- `langchain-mcp-adapters.MultiServerMCPClient` takes a dict keyed by server + name and produces a flat list of LangChain `Tool` objects, one per tool per + server. +- `langgraph.prebuilt.create_react_agent` wraps a chat model + tool list into + a runnable graph. That's your agent. +- Under the hood, each tool call gets routed to the right MCP session + automatically — you don't hand-write a routing table like in the + Anthropic-SDK example. + +When you need more than ReAct (branching, memory, human-in-the-loop), keep the +`mcp_client.get_tools()` line and build your own `StateGraph` around it. + +## What's pseudo-code, what isn't + +- The **imports** reflect the current `langchain-mcp-adapters` package layout, + but the project is young — rename to match what `pip show` reports. +- The **agent construction** (`create_react_agent`, `ChatAnthropic`, + `graph.astream`) is idiomatic LangGraph and stable. + +## Files + +- [`agent.py`](./agent.py) — the pseudo-code blueprint. +- [`requirements.txt`](./requirements.txt) — minimal deps. diff --git a/examples/agent-integration/langchain-langgraph/agent.py b/examples/agent-integration/langchain-langgraph/agent.py new file mode 100644 index 0000000..dd533a4 --- /dev/null +++ b/examples/agent-integration/langchain-langgraph/agent.py @@ -0,0 +1,66 @@ +"""LangGraph ReAct agent that pulls tools from three mcp-forge servers. + +PSEUDO-CODE. `langchain-mcp-adapters` is under active development; its import +paths and the exact shape of the client config dict have shifted between +releases. Pin a version and adapt imports to what `pip` actually installs. + +Run: + pip install -r requirements.txt + export ANTHROPIC_API_KEY=sk-ant-... + python agent.py +""" + +from __future__ import annotations + +import asyncio +import os + +from langchain_anthropic import ChatAnthropic +from langchain_mcp_adapters.client import MultiServerMCPClient +from langgraph.prebuilt import create_react_agent + + +SERVER_CONFIG = { + "search": { + "url": "http://localhost:8001/mcp", + "transport": "streamable_http", + }, + "knowledge": { + "url": "http://localhost:8002/mcp", + "transport": "streamable_http", + }, + "metrics": { + "url": "http://localhost:8003/mcp", + "transport": "streamable_http", + }, +} + +MODEL = "claude-sonnet-4-5" + + +async def build_graph(): + """Pull tools from every server and assemble a ReAct agent.""" + mcp_client = MultiServerMCPClient(SERVER_CONFIG) + tools = await mcp_client.get_tools() + + model = ChatAnthropic(model=MODEL, max_tokens=4096) + return create_react_agent(model, tools) + + +async def main() -> None: + if not os.getenv("ANTHROPIC_API_KEY"): + raise SystemExit("ANTHROPIC_API_KEY is not set") + + graph = await build_graph() + + user_message = "Find recent news about MCP and summarise in 3 bullets." + + async for event in graph.astream({"messages": [("user", user_message)]}): + for node_name, payload in event.items(): + messages = payload.get("messages", []) + if messages: + print(f"[{node_name}] {messages[-1].content}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agent-integration/langchain-langgraph/requirements.txt b/examples/agent-integration/langchain-langgraph/requirements.txt new file mode 100644 index 0000000..13e3dfc --- /dev/null +++ b/examples/agent-integration/langchain-langgraph/requirements.txt @@ -0,0 +1,4 @@ +langchain>=0.3.0 +langgraph>=0.2.0 +langchain-anthropic>=0.3.0 +langchain-mcp-adapters>=0.1.0