chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
# Semantic Kernel as MCP Server
|
||||
|
||||
This sample demonstrates how to expose your Semantic Kernel instance or a Agent as an MCP (Model Context Protocol) server.
|
||||
|
||||
## Getting Started with Stdio
|
||||
|
||||
To run these samples using the `stdio` transport (default), set up your MCP host (like [Claude Desktop](https://claude.ai/download) or [VSCode GitHub Copilot Agents](https://code.visualstudio.com/docs/copilot/chat/mcp-servers)) with the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sk": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server",
|
||||
"run",
|
||||
"sk_mcp_server.py"
|
||||
],
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "<your_openai_api_key>",
|
||||
"OPENAI_CHAT_MODEL_ID": "gpt-4o-mini"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server",
|
||||
"run",
|
||||
"agent_mcp_server.py"
|
||||
],
|
||||
"env": {
|
||||
"AZURE_AI_AGENT_PROJECT_CONNECTION_STRING": "<your azure connection string>",
|
||||
"AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME": "<your azure model deployment name>",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, you can run the server directly with the following command:
|
||||
|
||||
```bash
|
||||
uv --directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server run sk_mcp_server.py
|
||||
```
|
||||
or:
|
||||
```bash
|
||||
uv --directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server run agent_mcp_server.py
|
||||
```
|
||||
|
||||
## Getting Started with SSE
|
||||
|
||||
To run these samples as an SSE (Server-Sent Events) server, set the same environment variables as above and run the following command:
|
||||
|
||||
```bash
|
||||
uv --directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server run sk_mcp_server.py --transport sse --port 8000
|
||||
```
|
||||
or:
|
||||
```bash
|
||||
uv --directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server run agent_mcp_server.py --transport sse --port 8000
|
||||
```
|
||||
|
||||
This will start a server that listens for incoming requests on port `8000`.
|
||||
|
||||
> [!NOTE]
|
||||
> By default the SSE server binds to `127.0.0.1` (loopback) and only accepts requests
|
||||
> with a loopback `Host` header and, when present, a loopback `Origin` header. A local
|
||||
> MCP server exposes tools, plugins and model providers backed by your own credentials,
|
||||
> so it is good practice to keep it reachable only from your own machine. The
|
||||
> [MCP specification](https://modelcontextprotocol.io/) recommends validating `Origin`
|
||||
> and binding to loopback, in part to guard against [DNS rebinding](https://en.wikipedia.org/wiki/DNS_rebinding).
|
||||
>
|
||||
> You can override the bind address with `--host`, e.g. `--host 0.0.0.0` to expose the
|
||||
> server on the network. Do this only on a trusted network. The bundled Host/Origin
|
||||
> checks only allow loopback callers, so a non-loopback deployment needs proper
|
||||
> authentication - see the [`mcp_with_oauth`](../mcp_with_oauth/) sample for the
|
||||
> authenticated, Streamable-HTTP pattern recommended for production.
|
||||
|
||||
---
|
||||
|
||||
In both cases, `uv` will ensure that `semantic-kernel` is installed with the `mcp` extra in a temporary virtual environment.
|
||||
|
||||
## Extending the sample
|
||||
|
||||
The *sk_mcp_server* sample creates two functions:
|
||||
|
||||
- `echo-echo_function`: A simple function that echoes back the input.
|
||||
- `prompt-prompt`: a function that uses a Semantic Kernel prompt to generate a response.
|
||||
|
||||
The *agent_mcp_server* sample creates a simple agent that uses the Azure OpenAI service to generate a response.
|
||||
It exposes a single function:
|
||||
|
||||
- `mcp-host`: A function that uses the Azure OpenAI service to generate a response.
|
||||
|
||||
Once the server is created, you get a `mcp.server.lowlevel.Server` object, which you can then extend to add further functionality, like resources or prompts.
|
||||
@@ -0,0 +1,228 @@
|
||||
# /// script # noqa: CPY001
|
||||
# dependencies = [
|
||||
# "semantic-kernel[mcp]",
|
||||
# ]
|
||||
# ///
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
import argparse
|
||||
import ipaddress
|
||||
import logging
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
import anyio
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
from semantic_kernel.agents import AzureAIAgent, AzureAIAgentSettings
|
||||
from semantic_kernel.functions import kernel_function
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
"""
|
||||
This sample demonstrates how to expose an Agent as a MCP server.
|
||||
|
||||
To run this sample, set up your MCP host (like Claude Desktop or VSCode Github Copilot Agents)
|
||||
with the following configuration:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sk": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server",
|
||||
"run",
|
||||
"agent_mcp_server.py"
|
||||
],
|
||||
"env": {
|
||||
"AZURE_AI_AGENT_PROJECT_CONNECTION_STRING": "<your azure connection string>",
|
||||
"AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME": "<your azure model deployment name>",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Alternatively, you can run this as a SSE server, by setting the same environment variables as above,
|
||||
and running the following command:
|
||||
```bash
|
||||
uv --directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server \
|
||||
run agent_mcp_server.py --transport sse --port 8000
|
||||
```
|
||||
This will start a server that listens for incoming requests on port 8000.
|
||||
|
||||
In both cases, uv will make sure to install semantic-kernel with the mcp extra for you in a temporary venv.
|
||||
"""
|
||||
|
||||
|
||||
def is_loopback_host(host: str) -> bool:
|
||||
"""Return True if the host refers to a loopback interface (incl. IPv6 ::1)."""
|
||||
if host == "localhost":
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
parser = argparse.ArgumentParser(description="Run the Semantic Kernel MCP server.")
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
type=str,
|
||||
choices=["sse", "stdio"],
|
||||
default="stdio",
|
||||
help="Transport method to use (default: stdio).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Port to use for SSE transport (required if transport is 'sse').",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
type=str,
|
||||
default="127.0.0.1",
|
||||
help=(
|
||||
"Host/interface to bind the SSE server to (default: 127.0.0.1). "
|
||||
"Binding to anything other than loopback (e.g. 0.0.0.0) exposes the server "
|
||||
"to the network and should only be done on a trusted network with authentication added."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.transport == "sse" and args.port is None:
|
||||
parser.error("--port is required when --transport is 'sse'.")
|
||||
return args
|
||||
|
||||
|
||||
# Define a simple plugin for the sample
|
||||
class MenuPlugin:
|
||||
"""A sample Menu Plugin used for the sample."""
|
||||
|
||||
@kernel_function(description="Provides a list of specials from the menu.")
|
||||
def get_specials(self) -> Annotated[str, "Returns the specials from the menu."]:
|
||||
return """
|
||||
Special Soup: Clam Chowder
|
||||
Special Salad: Cobb Salad
|
||||
Special Drink: Chai Tea
|
||||
"""
|
||||
|
||||
@kernel_function(description="Provides the price of the requested menu item.")
|
||||
def get_item_price(
|
||||
self, menu_item: Annotated[str, "The name of the menu item."]
|
||||
) -> Annotated[str, "Returns the price of the menu item."]:
|
||||
return "$9.99"
|
||||
|
||||
|
||||
async def run(transport: Literal["sse", "stdio"] = "stdio", port: int | None = None, host: str = "127.0.0.1") -> None:
|
||||
async with (
|
||||
# 1. Login to Azure and create a Azure AI Project Client
|
||||
AzureCliCredential() as creds,
|
||||
AzureAIAgent.create_client(credential=creds) as client,
|
||||
):
|
||||
agent = AzureAIAgent(
|
||||
client=client,
|
||||
definition=await client.agents.create_agent(
|
||||
model=AzureAIAgentSettings().model_deployment_name,
|
||||
name="Host",
|
||||
instructions="Answer questions about the menu.",
|
||||
),
|
||||
plugins=[MenuPlugin()], # add the sample plugin to the agent
|
||||
)
|
||||
server = agent.as_mcp_server()
|
||||
|
||||
if transport == "sse" and port is not None:
|
||||
import nest_asyncio
|
||||
import uvicorn
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
# A local MCP server is a security boundary, not a generic web server: it exposes
|
||||
# tools, plugins and model providers backed by the developer's credentials. Without
|
||||
# Host/Origin validation a malicious web page could use DNS rebinding to reach this
|
||||
# loopback listener from the victim's browser and invoke the exposed MCP tools.
|
||||
# The MCP spec therefore requires servers to validate Origin and bind to loopback.
|
||||
allowed_hosts = [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"[::1]",
|
||||
f"localhost:{port}",
|
||||
f"127.0.0.1:{port}",
|
||||
f"[::1]:{port}",
|
||||
]
|
||||
allowed_origins = {
|
||||
"http://localhost",
|
||||
"http://127.0.0.1",
|
||||
"http://[::1]",
|
||||
f"http://localhost:{port}",
|
||||
f"http://127.0.0.1:{port}",
|
||||
f"http://[::1]:{port}",
|
||||
}
|
||||
|
||||
class OriginValidationMiddleware:
|
||||
"""Reject requests with an untrusted Origin header (DNS-rebinding defense)."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] == "http":
|
||||
origin = dict(scope["headers"]).get(b"origin")
|
||||
if origin is not None:
|
||||
try:
|
||||
origin_value = origin.decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
origin_value = None
|
||||
if origin_value not in allowed_origins:
|
||||
response = PlainTextResponse("Forbidden: invalid Origin header", status_code=403)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
sse = SseServerTransport("/messages/")
|
||||
|
||||
async def handle_sse(request):
|
||||
async with sse.connect_sse(request.scope, request.receive, request._send) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
):
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||
|
||||
starlette_app = Starlette(
|
||||
debug=False,
|
||||
routes=[
|
||||
Route("/sse", endpoint=handle_sse),
|
||||
Mount("/messages/", app=sse.handle_post_message),
|
||||
],
|
||||
middleware=[
|
||||
Middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts),
|
||||
Middleware(OriginValidationMiddleware),
|
||||
],
|
||||
)
|
||||
|
||||
if not is_loopback_host(host):
|
||||
logger.warning(
|
||||
"Binding the MCP SSE server to %s exposes it beyond loopback. The bundled Host/Origin "
|
||||
"checks only allow loopback callers; for a network-reachable or credentialed deployment "
|
||||
"add proper authentication (see the mcp_with_oauth sample) before doing this.",
|
||||
host,
|
||||
)
|
||||
|
||||
nest_asyncio.apply()
|
||||
uvicorn.run(starlette_app, host=host, port=port) # nosec
|
||||
elif transport == "stdio":
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
async def handle_stdin(stdin: Any | None = None, stdout: Any | None = None) -> None:
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||
|
||||
await handle_stdin()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_arguments()
|
||||
anyio.run(run, args.transport, args.port, args.host)
|
||||
@@ -0,0 +1,83 @@
|
||||
# /// script # noqa: CPY001
|
||||
# dependencies = [
|
||||
# "semantic-kernel[mcp]",
|
||||
# ]
|
||||
# ///
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
from semantic_kernel import Kernel
|
||||
from semantic_kernel.prompt_template import InputVariable, KernelPromptTemplate, PromptTemplateConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
"""
|
||||
This sample demonstrates how to expose a Semantic Kernel prompt through a MCP server.
|
||||
|
||||
To run this sample, set up your MCP host (like Claude Desktop or VSCode Github Copilot Agents)
|
||||
with the following configuration:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sk_release_notes": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server",
|
||||
"run",
|
||||
"mcp_server_with_prompts.py"
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Note: You might need to set the uv to it's full path.
|
||||
|
||||
"""
|
||||
|
||||
template = """{{$messages}}
|
||||
---
|
||||
Group the following PRs into one of these buckets for release notes, keeping the same order:
|
||||
|
||||
-New Features
|
||||
-Enhancements and Improvements
|
||||
-Bug Fixes
|
||||
-Python Package Updates
|
||||
|
||||
Include the output in raw markdown.
|
||||
"""
|
||||
|
||||
|
||||
def run() -> None:
|
||||
"""Run the MCP server with the release notes prompt template."""
|
||||
kernel = Kernel()
|
||||
prompt = KernelPromptTemplate(
|
||||
prompt_template_config=PromptTemplateConfig(
|
||||
name="release_notes_prompt",
|
||||
description="This creates the prompts for a full set of release notes based on the PR messages given.",
|
||||
template=template,
|
||||
input_variables=[
|
||||
InputVariable(
|
||||
name="messages",
|
||||
description="These are the PR messages, they are a single string with new lines.",
|
||||
is_required=True,
|
||||
json_schema='{"type": "string"}',
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
server = kernel.as_mcp_server(server_name="sk_release_notes", prompts=[prompt])
|
||||
|
||||
async def handle_stdin(stdin: Any | None = None, stdout: Any | None = None) -> None:
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||
|
||||
anyio.run(handle_stdin)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,116 @@
|
||||
# /// script # noqa: CPY001
|
||||
# dependencies = [
|
||||
# "semantic-kernel[mcp]",
|
||||
# ]
|
||||
# ///
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
import anyio
|
||||
from mcp import types
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
from semantic_kernel import Kernel
|
||||
from semantic_kernel.functions import kernel_function
|
||||
from semantic_kernel.prompt_template import InputVariable, KernelPromptTemplate, PromptTemplateConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
"""
|
||||
This sample demonstrates how to expose your Semantic Kernel `kernel` instance as a MCP server, with the a function
|
||||
that uses sampling (see the docs: https://modelcontextprotocol.io/docs/concepts/sampling) to generate release notes.
|
||||
|
||||
To run this sample, set up your MCP host (like Claude Desktop or VSCode Github Copilot Agents)
|
||||
with the following configuration:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sk_release_notes": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server",
|
||||
"run",
|
||||
"mcp_server_with_prompts.py"
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: You might need to set the uv to it's full path.
|
||||
"""
|
||||
|
||||
template = """{{$messages}}
|
||||
---
|
||||
Group the following PRs into one of these buckets for release notes, keeping the same order:
|
||||
|
||||
-New Features
|
||||
-Enhancements and Improvements
|
||||
-Bug Fixes
|
||||
-Python Package Updates
|
||||
|
||||
Include the output in raw markdown.
|
||||
"""
|
||||
|
||||
|
||||
@kernel_function(
|
||||
name="run_prompt",
|
||||
description="This run the prompts for a full set of release notes based on the PR messages given.",
|
||||
)
|
||||
async def sampling_function(
|
||||
messages: Annotated[str, "The list of PR messages, as a string with newlines"],
|
||||
temperature: float = 0.0,
|
||||
max_tokens: int = 1000,
|
||||
# The include_in_function_choices is set to False, so it won't be included in the function choices,
|
||||
# but it will get the server instance from the MCPPlugin that consumes this server.
|
||||
server: Annotated[Server | None, "The server session", {"include_in_function_choices": False}] = None,
|
||||
) -> str:
|
||||
if not server:
|
||||
raise ValueError("Request context is required for sampling function.")
|
||||
sampling_response = await server.request_context.session.create_message(
|
||||
messages=[
|
||||
types.SamplingMessage(role="user", content=types.TextContent(type="text", text=messages)),
|
||||
],
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
model_preferences=types.ModelPreferences(
|
||||
hints=[types.ModelHint(name="gpt-4o-mini")],
|
||||
),
|
||||
)
|
||||
logger.info(f"Sampling response: {sampling_response}")
|
||||
return sampling_response.content.text
|
||||
|
||||
|
||||
def run() -> None:
|
||||
"""Run the MCP server with the release notes prompt template."""
|
||||
kernel = Kernel()
|
||||
kernel.add_function("release_notes", sampling_function)
|
||||
prompt = KernelPromptTemplate(
|
||||
prompt_template_config=PromptTemplateConfig(
|
||||
name="release_notes_prompt",
|
||||
description="This creates the prompts for a full set of release notes based on the PR messages given.",
|
||||
template=template,
|
||||
input_variables=[
|
||||
InputVariable(
|
||||
name="messages",
|
||||
description="These are the PR messages, they are a single string with new lines.",
|
||||
is_required=True,
|
||||
json_schema='{"type": "string"}',
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
server = kernel.as_mcp_server(server_name="sk_release_notes", prompts=[prompt])
|
||||
|
||||
async def handle_stdin(stdin: Any | None = None, stdout: Any | None = None) -> None:
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||
|
||||
anyio.run(handle_stdin)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,227 @@
|
||||
# /// script # noqa: CPY001
|
||||
# dependencies = [
|
||||
# "semantic-kernel[mcp]",
|
||||
# ]
|
||||
# ///
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
import argparse
|
||||
import ipaddress
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from semantic_kernel import Kernel
|
||||
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
|
||||
from semantic_kernel.functions import kernel_function
|
||||
from semantic_kernel.prompt_template import PromptTemplateConfig
|
||||
from semantic_kernel.prompt_template.input_variable import InputVariable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
"""
|
||||
This sample demonstrates how to expose your Semantic Kernel `kernel` instance as a MCP server.
|
||||
|
||||
To run this sample, set up your MCP host (like Claude Desktop or VSCode Github Copilot Agents)
|
||||
with the following configuration:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sk": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server",
|
||||
"run",
|
||||
"sk_mcp_server.py"
|
||||
],
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "<your_openai_api_key>",
|
||||
"OPENAI_CHAT_MODEL_ID": "gpt-4o-mini"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: You might need to set the uv to its full path.
|
||||
|
||||
Alternatively, you can run this as a SSE server, by setting the same environment variables as above,
|
||||
and running the following command:
|
||||
```bash
|
||||
uv --directory=<path to sk project>/semantic-kernel/python/samples/demos/mcp_server \
|
||||
run sk_mcp_server.py --transport sse --port 8000
|
||||
```
|
||||
This will start a server that listens for incoming requests on port 8000.
|
||||
|
||||
In both cases, uv will make sure to install semantic-kernel with the mcp extra for you in a temporary venv.
|
||||
"""
|
||||
|
||||
|
||||
def is_loopback_host(host: str) -> bool:
|
||||
"""Return True if the host refers to a loopback interface (incl. IPv6 ::1)."""
|
||||
if host == "localhost":
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
parser = argparse.ArgumentParser(description="Run the Semantic Kernel MCP server.")
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
type=str,
|
||||
choices=["sse", "stdio"],
|
||||
default="stdio",
|
||||
help="Transport method to use (default: stdio).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Port to use for SSE transport (required if transport is 'sse').",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
type=str,
|
||||
default="127.0.0.1",
|
||||
help=(
|
||||
"Host/interface to bind the SSE server to (default: 127.0.0.1). "
|
||||
"Binding to anything other than loopback (e.g. 0.0.0.0) exposes the server "
|
||||
"to the network and should only be done on a trusted network with authentication added."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.transport == "sse" and args.port is None:
|
||||
parser.error("--port is required when --transport is 'sse'.")
|
||||
return args
|
||||
|
||||
|
||||
def run(transport: Literal["sse", "stdio"] = "stdio", port: int | None = None, host: str = "127.0.0.1") -> None:
|
||||
kernel = Kernel()
|
||||
|
||||
@kernel_function()
|
||||
def echo_function(message: str, extra: str = "") -> str:
|
||||
"""Echo a message as a function"""
|
||||
return f"Function echo: {message} {extra}"
|
||||
|
||||
kernel.add_service(OpenAIChatCompletion(service_id="default"))
|
||||
kernel.add_function("echo", echo_function, "echo_function")
|
||||
kernel.add_function(
|
||||
plugin_name="prompt",
|
||||
function_name="prompt",
|
||||
prompt_template_config=PromptTemplateConfig(
|
||||
name="prompt",
|
||||
description="This is a prompt",
|
||||
template="Please repeat this: {{$message}} and this: {{$extra}}",
|
||||
input_variables=[
|
||||
InputVariable(
|
||||
name="message",
|
||||
description="This is the message.",
|
||||
is_required=True,
|
||||
json_schema='{ "type": "string", "description": "This is the message."}',
|
||||
),
|
||||
InputVariable(
|
||||
name="extra",
|
||||
description="This is extra.",
|
||||
default="default",
|
||||
is_required=False,
|
||||
json_schema='{ "type": "string", "description": "This is the message."}',
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
server = kernel.as_mcp_server(server_name="sk")
|
||||
|
||||
if transport == "sse" and port is not None:
|
||||
import uvicorn
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
# A local MCP server is a security boundary, not a generic web server: it exposes
|
||||
# tools, plugins and model providers backed by the developer's credentials. Without
|
||||
# Host/Origin validation a malicious web page could use DNS rebinding to reach this
|
||||
# loopback listener from the victim's browser and invoke the exposed MCP tools.
|
||||
# The MCP spec therefore requires servers to validate Origin and bind to loopback.
|
||||
allowed_hosts = [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"[::1]",
|
||||
f"localhost:{port}",
|
||||
f"127.0.0.1:{port}",
|
||||
f"[::1]:{port}",
|
||||
]
|
||||
allowed_origins = {
|
||||
"http://localhost",
|
||||
"http://127.0.0.1",
|
||||
"http://[::1]",
|
||||
f"http://localhost:{port}",
|
||||
f"http://127.0.0.1:{port}",
|
||||
f"http://[::1]:{port}",
|
||||
}
|
||||
|
||||
class OriginValidationMiddleware:
|
||||
"""Reject requests with an untrusted Origin header (DNS-rebinding defense)."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] == "http":
|
||||
origin = dict(scope["headers"]).get(b"origin")
|
||||
if origin is not None:
|
||||
try:
|
||||
origin_value = origin.decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
origin_value = None
|
||||
if origin_value not in allowed_origins:
|
||||
response = PlainTextResponse("Forbidden: invalid Origin header", status_code=403)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
sse = SseServerTransport("/messages/")
|
||||
|
||||
async def handle_sse(request):
|
||||
async with sse.connect_sse(request.scope, request.receive, request._send) as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||
|
||||
starlette_app = Starlette(
|
||||
debug=False,
|
||||
routes=[
|
||||
Route("/sse", endpoint=handle_sse),
|
||||
Mount("/messages/", app=sse.handle_post_message),
|
||||
],
|
||||
middleware=[
|
||||
Middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts),
|
||||
Middleware(OriginValidationMiddleware),
|
||||
],
|
||||
)
|
||||
|
||||
if not is_loopback_host(host):
|
||||
logger.warning(
|
||||
"Binding the MCP SSE server to %s exposes it beyond loopback. The bundled Host/Origin "
|
||||
"checks only allow loopback callers; for a network-reachable or credentialed deployment "
|
||||
"add proper authentication (see the mcp_with_oauth sample) before doing this.",
|
||||
host,
|
||||
)
|
||||
|
||||
uvicorn.run(starlette_app, host=host, port=port) # nosec
|
||||
elif transport == "stdio":
|
||||
import anyio
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
async def handle_stdin(stdin: Any | None = None, stdout: Any | None = None) -> None:
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||
|
||||
anyio.run(handle_stdin)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_arguments()
|
||||
run(transport=args.transport, port=args.port, host=args.host)
|
||||
Reference in New Issue
Block a user