microsoft-teams-mcpplugin 0.0.1a5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- microsoft_teams_mcpplugin-0.0.1a5/.gitignore +39 -0
- microsoft_teams_mcpplugin-0.0.1a5/PKG-INFO +13 -0
- microsoft_teams_mcpplugin-0.0.1a5/README.md +0 -0
- microsoft_teams_mcpplugin-0.0.1a5/pyproject.toml +34 -0
- microsoft_teams_mcpplugin-0.0.1a5/src/microsoft/teams/mcpplugin/__init__.py +12 -0
- microsoft_teams_mcpplugin-0.0.1a5/src/microsoft/teams/mcpplugin/ai_plugin.py +226 -0
- microsoft_teams_mcpplugin-0.0.1a5/src/microsoft/teams/mcpplugin/models/__init__.py +10 -0
- microsoft_teams_mcpplugin-0.0.1a5/src/microsoft/teams/mcpplugin/models/cache.py +22 -0
- microsoft_teams_mcpplugin-0.0.1a5/src/microsoft/teams/mcpplugin/models/params.py +26 -0
- microsoft_teams_mcpplugin-0.0.1a5/src/microsoft/teams/mcpplugin/models/tool.py +16 -0
- microsoft_teams_mcpplugin-0.0.1a5/src/microsoft/teams/mcpplugin/server_plugin.py +150 -0
- microsoft_teams_mcpplugin-0.0.1a5/src/microsoft/teams/mcpplugin/transport.py +69 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
|
|
7
|
+
# Environments
|
|
8
|
+
.env
|
|
9
|
+
.venv
|
|
10
|
+
env/
|
|
11
|
+
venv/
|
|
12
|
+
ENV/
|
|
13
|
+
env.bak/
|
|
14
|
+
venv.bak/
|
|
15
|
+
|
|
16
|
+
# mypy
|
|
17
|
+
.mypy_cache/
|
|
18
|
+
.dmypy.json
|
|
19
|
+
dmypy.json
|
|
20
|
+
|
|
21
|
+
.copilot-instructions.md
|
|
22
|
+
|
|
23
|
+
# other
|
|
24
|
+
.DS_STORE
|
|
25
|
+
*.bak
|
|
26
|
+
*~
|
|
27
|
+
*.tmp
|
|
28
|
+
|
|
29
|
+
ref/
|
|
30
|
+
py.typed
|
|
31
|
+
CLAUDE.md
|
|
32
|
+
|
|
33
|
+
.env.claude/
|
|
34
|
+
.claude/
|
|
35
|
+
|
|
36
|
+
tests/**/.vscode/
|
|
37
|
+
tests/**/appPackage/
|
|
38
|
+
tests/**/infra/
|
|
39
|
+
tests/**/teamsapp*
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: microsoft-teams-mcpplugin
|
|
3
|
+
Version: 0.0.1a5
|
|
4
|
+
Summary: library for handling mcp with teams ai library
|
|
5
|
+
Author-email: Microsoft <TeamsAISDKFeedback@microsoft.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: agents,ai,bot,microsoft,teams
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: fastmcp>=0.5.0
|
|
10
|
+
Requires-Dist: mcp>=1.13.1
|
|
11
|
+
Requires-Dist: microsoft-teams-ai
|
|
12
|
+
Requires-Dist: microsoft-teams-apps
|
|
13
|
+
Requires-Dist: microsoft-teams-common
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "microsoft-teams-mcpplugin"
|
|
3
|
+
version = "0.0.1a5"
|
|
4
|
+
description = "library for handling mcp with teams ai library"
|
|
5
|
+
authors = [{ name = "Microsoft", email = "TeamsAISDKFeedback@microsoft.com" }]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
repository = "https://github.com/microsoft/teams.py"
|
|
9
|
+
keywords = ["microsoft", "teams", "ai", "bot", "agents"]
|
|
10
|
+
license = "MIT"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"mcp>=1.13.1",
|
|
13
|
+
"microsoft-teams-common",
|
|
14
|
+
"fastmcp>=0.5.0",
|
|
15
|
+
"microsoft-teams-apps",
|
|
16
|
+
"microsoft-teams-ai"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[tool.microsoft-teams.metadata]
|
|
20
|
+
external = true
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/microsoft"]
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.sdist]
|
|
30
|
+
include = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.uv.sources]
|
|
33
|
+
microsoft-teams-ai = { workspace = true }
|
|
34
|
+
microsoft-teams-common = { workspace = true }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from . import models
|
|
7
|
+
from .ai_plugin import McpClientPlugin, McpClientPluginParams, McpToolDetails
|
|
8
|
+
from .models import * # noqa: F403
|
|
9
|
+
from .server_plugin import McpServerPlugin
|
|
10
|
+
|
|
11
|
+
__all__: list[str] = ["McpClientPlugin", "McpClientPluginParams", "McpToolDetails", "McpServerPlugin"]
|
|
12
|
+
__all__.extend(models.__all__)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
11
|
+
|
|
12
|
+
from mcp import ClientSession
|
|
13
|
+
from mcp.types import TextContent
|
|
14
|
+
from microsoft.teams.ai.function import Function
|
|
15
|
+
from microsoft.teams.ai.plugin import BaseAIPlugin
|
|
16
|
+
from microsoft.teams.common.logging import ConsoleLogger
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from .models import McpCachedValue, McpClientPluginParams, McpToolDetails
|
|
20
|
+
from .transport import create_transport
|
|
21
|
+
|
|
22
|
+
REFETCH_TIMEOUT_MS = 24 * 60 * 60 * 1000 # 1 day
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class McpClientPlugin(BaseAIPlugin):
|
|
26
|
+
"""MCP Client Plugin for Teams AI integration."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
name: str = "mcp_client",
|
|
31
|
+
version: str = "0.0.0",
|
|
32
|
+
cache: Optional[Dict[str, McpCachedValue]] = None,
|
|
33
|
+
logger: Optional[logging.Logger] = None,
|
|
34
|
+
refetch_timeout_ms: int = REFETCH_TIMEOUT_MS, # 1 day
|
|
35
|
+
):
|
|
36
|
+
super().__init__(name)
|
|
37
|
+
|
|
38
|
+
self._version = version
|
|
39
|
+
self._cache: Dict[str, McpCachedValue] = cache or {}
|
|
40
|
+
self._logger = logger.getChild(self.name) if logger else ConsoleLogger().create_logger(self.name)
|
|
41
|
+
self._refetch_timeout_ms = refetch_timeout_ms
|
|
42
|
+
|
|
43
|
+
# If cache is provided, update last_fetched for entries with tools
|
|
44
|
+
if cache:
|
|
45
|
+
current_time = time.time() * 1000
|
|
46
|
+
for cached_value in cache.values():
|
|
47
|
+
if cached_value.available_tools and not cached_value.last_fetched:
|
|
48
|
+
cached_value.last_fetched = current_time
|
|
49
|
+
|
|
50
|
+
# Track MCP server URLs and their parameters
|
|
51
|
+
self._mcp_server_params: Dict[str, McpClientPluginParams] = {}
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def version(self) -> str:
|
|
55
|
+
"""Get the plugin version."""
|
|
56
|
+
return self._version
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def cache(self) -> Dict[str, McpCachedValue]:
|
|
60
|
+
"""Get the plugin cache."""
|
|
61
|
+
return self._cache
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def refetch_timeout_ms(self) -> int:
|
|
65
|
+
"""Get the refetch timeout in milliseconds."""
|
|
66
|
+
return self._refetch_timeout_ms
|
|
67
|
+
|
|
68
|
+
def use_mcp_server(self, url: str, params: Optional[McpClientPluginParams] = None) -> None:
|
|
69
|
+
"""Add or updates an MCP server to be used by this plugin."""
|
|
70
|
+
self._mcp_server_params[url] = params or McpClientPluginParams()
|
|
71
|
+
|
|
72
|
+
# Update cache if tools are provided
|
|
73
|
+
if params and params.available_tools:
|
|
74
|
+
self._cache[url] = McpCachedValue(
|
|
75
|
+
transport=params.transport,
|
|
76
|
+
available_tools=params.available_tools,
|
|
77
|
+
last_fetched=time.time() * 1000, # Set to current time in milliseconds
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
async def on_build_functions(self, functions: List[Function[BaseModel]]) -> List[Function[BaseModel]]:
|
|
81
|
+
"""Build functions from MCP tools."""
|
|
82
|
+
await self._fetch_tools_if_needed()
|
|
83
|
+
|
|
84
|
+
# Create functions from cached tools
|
|
85
|
+
all_functions = list(functions)
|
|
86
|
+
|
|
87
|
+
for url, params in self._mcp_server_params.items():
|
|
88
|
+
cached_data = self._cache.get(url)
|
|
89
|
+
available_tools = cached_data.available_tools if cached_data else []
|
|
90
|
+
|
|
91
|
+
for tool in available_tools:
|
|
92
|
+
# Create a function for each tool
|
|
93
|
+
function = self._create_function_from_tool(url, tool, params)
|
|
94
|
+
all_functions.append(function)
|
|
95
|
+
|
|
96
|
+
return all_functions
|
|
97
|
+
|
|
98
|
+
async def _fetch_tools_if_needed(self) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Fetch tools from MCP servers if needed.
|
|
101
|
+
|
|
102
|
+
We check if there the cached value has met its expiration
|
|
103
|
+
for being refetched. Or if the tools have never been fetched at all
|
|
104
|
+
"""
|
|
105
|
+
fetch_needed: List[Tuple[str, McpClientPluginParams]] = []
|
|
106
|
+
current_time = time.time() * 1000 # Convert to milliseconds
|
|
107
|
+
|
|
108
|
+
for url, params in self._mcp_server_params.items():
|
|
109
|
+
# Skip if tools are explicitly provided
|
|
110
|
+
if params.available_tools:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
cached_data = self._cache.get(url)
|
|
114
|
+
should_fetch = (
|
|
115
|
+
not cached_data
|
|
116
|
+
or not cached_data.available_tools
|
|
117
|
+
or not cached_data.last_fetched
|
|
118
|
+
or (current_time - cached_data.last_fetched) > (params.refetch_timeout_ms or self._refetch_timeout_ms)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if should_fetch:
|
|
122
|
+
fetch_needed.append((url, params))
|
|
123
|
+
|
|
124
|
+
# Fetch tools in parallel
|
|
125
|
+
if fetch_needed:
|
|
126
|
+
tasks = [self._fetch_tools_from_server(url, params) for url, params in fetch_needed]
|
|
127
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
128
|
+
|
|
129
|
+
for i, (url, params) in enumerate(fetch_needed):
|
|
130
|
+
result = results[i]
|
|
131
|
+
if isinstance(result, Exception):
|
|
132
|
+
self._logger.error(f"Failed to fetch tools from {url}: {result}")
|
|
133
|
+
if not params.skip_if_unavailable:
|
|
134
|
+
raise result
|
|
135
|
+
elif isinstance(result, list):
|
|
136
|
+
# Update cache with fetched tools
|
|
137
|
+
if url not in self._cache:
|
|
138
|
+
self._cache[url] = McpCachedValue()
|
|
139
|
+
self._cache[url].available_tools = result
|
|
140
|
+
self._cache[url].last_fetched = current_time
|
|
141
|
+
self._cache[url].transport = params.transport
|
|
142
|
+
|
|
143
|
+
self._logger.debug(f"Cached {len(result)} tools for {url}")
|
|
144
|
+
|
|
145
|
+
def _create_function_from_tool(
|
|
146
|
+
self, url: str, tool: McpToolDetails, plugin_params: McpClientPluginParams
|
|
147
|
+
) -> Function[BaseModel]:
|
|
148
|
+
"""Create a Teams AI function from an MCP tool."""
|
|
149
|
+
tool_name = tool.name
|
|
150
|
+
tool_description = tool.description
|
|
151
|
+
|
|
152
|
+
async def handler(params: BaseModel) -> str:
|
|
153
|
+
"""Handle MCP tool call."""
|
|
154
|
+
try:
|
|
155
|
+
self._logger.debug(f"Making call to {url} tool-name={tool_name}")
|
|
156
|
+
result = await self._call_mcp_tool(url, tool_name, params.model_dump(), plugin_params)
|
|
157
|
+
self._logger.debug(f"Successfully received result for mcp call {result}")
|
|
158
|
+
return str(result)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
self._logger.error(f"Error calling tool {tool_name} on {url}: {e}")
|
|
161
|
+
raise
|
|
162
|
+
|
|
163
|
+
return Function(
|
|
164
|
+
name=tool_name, description=tool_description, parameter_schema=tool.input_schema, handler=handler
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
async def _fetch_tools_from_server(self, url: str, params: McpClientPluginParams) -> List[McpToolDetails]:
|
|
168
|
+
"""Fetch tools from a specific MCP server."""
|
|
169
|
+
transport_context = create_transport(url, params.transport or "streamable_http", params.headers)
|
|
170
|
+
|
|
171
|
+
async with transport_context as (read_stream, write_stream):
|
|
172
|
+
async with ClientSession(read_stream, write_stream) as session:
|
|
173
|
+
# Initialize the connection
|
|
174
|
+
await session.initialize()
|
|
175
|
+
|
|
176
|
+
# List available tools
|
|
177
|
+
tools_response = await session.list_tools()
|
|
178
|
+
|
|
179
|
+
# Convert MCP tools to our format
|
|
180
|
+
tools: list[McpToolDetails] = []
|
|
181
|
+
for tool in tools_response.tools:
|
|
182
|
+
tools.append(
|
|
183
|
+
McpToolDetails(
|
|
184
|
+
name=tool.name, description=tool.description or "", input_schema=tool.inputSchema or {}
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
self._logger.debug(f"Got {len(tools)} tools for {url}")
|
|
189
|
+
return tools
|
|
190
|
+
|
|
191
|
+
async def _call_mcp_tool(
|
|
192
|
+
self, url: str, tool_name: str, arguments: Dict[str, Any], params: McpClientPluginParams
|
|
193
|
+
) -> Optional[Union[str, List[str]]]:
|
|
194
|
+
"""Call a specific tool on an MCP server."""
|
|
195
|
+
transport_context = create_transport(url, params.transport or "streamable_http", params.headers)
|
|
196
|
+
|
|
197
|
+
async with transport_context as (read_stream, write_stream):
|
|
198
|
+
async with ClientSession(read_stream, write_stream) as session:
|
|
199
|
+
# Initialize the connection
|
|
200
|
+
await session.initialize()
|
|
201
|
+
|
|
202
|
+
# Call the tool
|
|
203
|
+
result = await session.call_tool(tool_name, arguments)
|
|
204
|
+
|
|
205
|
+
# Return the content from the result
|
|
206
|
+
if result.content:
|
|
207
|
+
if len(result.content) == 1:
|
|
208
|
+
content_item = result.content[0]
|
|
209
|
+
if isinstance(content_item, TextContent):
|
|
210
|
+
return content_item.text
|
|
211
|
+
else:
|
|
212
|
+
return str(content_item)
|
|
213
|
+
else:
|
|
214
|
+
contents: list[str] = []
|
|
215
|
+
for item in result.content:
|
|
216
|
+
if isinstance(item, TextContent):
|
|
217
|
+
contents.append(item.text)
|
|
218
|
+
else:
|
|
219
|
+
try:
|
|
220
|
+
contents.append(json.dumps(item, default=str, ensure_ascii=False))
|
|
221
|
+
except (TypeError, ValueError) as e:
|
|
222
|
+
self._logger.warning(f"Failed to serialize content item: {e}")
|
|
223
|
+
contents.append(str(item))
|
|
224
|
+
return contents
|
|
225
|
+
|
|
226
|
+
return None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .cache import McpCachedValue
|
|
7
|
+
from .params import McpClientPluginParams
|
|
8
|
+
from .tool import McpToolDetails
|
|
9
|
+
|
|
10
|
+
__all__ = ["McpCachedValue", "McpClientPluginParams", "McpToolDetails"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from .tool import McpToolDetails
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class McpCachedValue:
|
|
12
|
+
"""Cached value for MCP server data."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
transport: Optional[str] = None,
|
|
17
|
+
available_tools: Optional[List[McpToolDetails]] = None,
|
|
18
|
+
last_fetched: Optional[float] = None,
|
|
19
|
+
):
|
|
20
|
+
self.transport = transport
|
|
21
|
+
self.available_tools = available_tools or []
|
|
22
|
+
self.last_fetched = last_fetched
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Awaitable, Callable, List, Mapping, Optional, Union
|
|
7
|
+
|
|
8
|
+
from .tool import McpToolDetails
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class McpClientPluginParams:
|
|
12
|
+
"""Parameters for MCP client plugin configuration."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
transport: Optional[str] = "streamable_http",
|
|
17
|
+
available_tools: Optional[List[McpToolDetails]] = None,
|
|
18
|
+
headers: Optional[Mapping[str, Union[str, Callable[[], Union[str, Awaitable[str]]]]]] = None,
|
|
19
|
+
skip_if_unavailable: Optional[bool] = True,
|
|
20
|
+
refetch_timeout_ms: Optional[int] = None,
|
|
21
|
+
):
|
|
22
|
+
self.transport = transport
|
|
23
|
+
self.available_tools = available_tools
|
|
24
|
+
self.headers = headers
|
|
25
|
+
self.skip_if_unavailable = skip_if_unavailable
|
|
26
|
+
self.refetch_timeout_ms = refetch_timeout_ms
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class McpToolDetails(BaseModel):
|
|
12
|
+
"""Details of an MCP tool."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
description: str
|
|
16
|
+
input_schema: Dict[str, Any]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import importlib.metadata
|
|
7
|
+
import logging
|
|
8
|
+
from inspect import isawaitable
|
|
9
|
+
from typing import Annotated, Any, TypeVar
|
|
10
|
+
|
|
11
|
+
from fastmcp import FastMCP
|
|
12
|
+
from fastmcp.tools import FunctionTool
|
|
13
|
+
from microsoft.teams.ai import Function
|
|
14
|
+
from microsoft.teams.apps import (
|
|
15
|
+
DependencyMetadata,
|
|
16
|
+
HttpPlugin,
|
|
17
|
+
Plugin,
|
|
18
|
+
PluginBase,
|
|
19
|
+
PluginStartEvent,
|
|
20
|
+
)
|
|
21
|
+
from microsoft.teams.common.logging import ConsoleLogger
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
version = importlib.metadata.version("microsoft-teams-mcpplugin")
|
|
26
|
+
except importlib.metadata.PackageNotFoundError:
|
|
27
|
+
version = "0.0.1-alpha.1"
|
|
28
|
+
|
|
29
|
+
P = TypeVar("P", bound=BaseModel)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@Plugin(
|
|
33
|
+
name="mcp-server", version=version, description="MCP server plugin that exposes Teams AI functions as MCP tools"
|
|
34
|
+
)
|
|
35
|
+
class McpServerPlugin(PluginBase):
|
|
36
|
+
"""
|
|
37
|
+
MCP Server Plugin for Teams Apps.
|
|
38
|
+
|
|
39
|
+
This plugin wraps FastMCP and provides a bridge between Teams AI Functions
|
|
40
|
+
and MCP tools, exposing them via streamable HTTP transport.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
# Dependency injection
|
|
44
|
+
http: Annotated[HttpPlugin, DependencyMetadata()]
|
|
45
|
+
|
|
46
|
+
def __init__(self, name: str = "teams-mcp-server", path: str = "/mcp", logger: logging.Logger | None = None):
|
|
47
|
+
"""
|
|
48
|
+
Initialize the MCP server plugin.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
name: The name of the MCP server
|
|
52
|
+
path: The path to mount the MCP server on (default: /mcp)
|
|
53
|
+
"""
|
|
54
|
+
self.mcp_server = FastMCP(name)
|
|
55
|
+
self.path = path
|
|
56
|
+
self._mounted = False
|
|
57
|
+
self.logger = logger or ConsoleLogger().create_logger("mcp-server")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def server(self) -> FastMCP:
|
|
61
|
+
"""Get the underlying FastMCP server."""
|
|
62
|
+
return self.mcp_server
|
|
63
|
+
|
|
64
|
+
def use_tool(self, function: Function[P]) -> "McpServerPlugin":
|
|
65
|
+
"""
|
|
66
|
+
Add a Teams AI function as an MCP tool.
|
|
67
|
+
|
|
68
|
+
This a convenience wrapper on top of the underlying FastMCP's add_tool.
|
|
69
|
+
Use it like:
|
|
70
|
+
```py
|
|
71
|
+
mcp_server_plugin.use_tool(my_fn_definition)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
If you'd like to use that directly, you can call
|
|
75
|
+
```py
|
|
76
|
+
@mcp_server_plugin.server.tool
|
|
77
|
+
def my_fn_definition(arg1: int, arg2: str): bool
|
|
78
|
+
...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
function: The Teams AI function to register as an MCP tool
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Self for method chaining
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
# Prepare parameter schema for FastMCP
|
|
89
|
+
parameter_schema = (
|
|
90
|
+
function.parameter_schema
|
|
91
|
+
if isinstance(function.parameter_schema, dict)
|
|
92
|
+
else function.parameter_schema.model_json_schema()
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Create wrapper handler that converts kwargs to the expected format
|
|
96
|
+
async def wrapped_handler(**kwargs: Any) -> Any:
|
|
97
|
+
try:
|
|
98
|
+
if isinstance(function.parameter_schema, type):
|
|
99
|
+
# parameter_schema is a Pydantic model class - instantiate it
|
|
100
|
+
params = function.parameter_schema(**kwargs)
|
|
101
|
+
result = function.handler(params)
|
|
102
|
+
else:
|
|
103
|
+
# parameter_schema is a dict - pass kwargs directly
|
|
104
|
+
result = function.handler(**kwargs)
|
|
105
|
+
|
|
106
|
+
# Handle both sync and async handlers
|
|
107
|
+
if isawaitable(result):
|
|
108
|
+
return await result
|
|
109
|
+
return result
|
|
110
|
+
except Exception as e:
|
|
111
|
+
self.logger.error(f"Function execution failed for '{function.name}': {e}")
|
|
112
|
+
raise
|
|
113
|
+
|
|
114
|
+
function_tool = FunctionTool(
|
|
115
|
+
name=function.name, description=function.description, parameters=parameter_schema, fn=wrapped_handler
|
|
116
|
+
)
|
|
117
|
+
self.mcp_server.add_tool(function_tool)
|
|
118
|
+
|
|
119
|
+
self.logger.debug(f"Registered Teams AI function '{function.name}' as MCP tool")
|
|
120
|
+
|
|
121
|
+
return self
|
|
122
|
+
except Exception as e:
|
|
123
|
+
self.logger.error(f"Failed to register function '{function.name}' as MCP tool: {e}")
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
async def on_start(self, event: PluginStartEvent) -> None:
|
|
127
|
+
"""Start the plugin - mount MCP server on HTTP plugin."""
|
|
128
|
+
|
|
129
|
+
if self._mounted:
|
|
130
|
+
self.logger.warning("MCP server already mounted")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
# We mount the mcp server as a separate app at self.path
|
|
135
|
+
mcp_http_app = self.mcp_server.http_app(path=self.path, transport="http")
|
|
136
|
+
self.http.lifespans.append(mcp_http_app.lifespan)
|
|
137
|
+
self.http.app.mount("/", mcp_http_app)
|
|
138
|
+
|
|
139
|
+
self._mounted = True
|
|
140
|
+
|
|
141
|
+
self.logger.info(f"MCP server mounted at {self.path}")
|
|
142
|
+
except Exception as e:
|
|
143
|
+
self.logger.error(f"Failed to mount MCP server: {e}")
|
|
144
|
+
raise
|
|
145
|
+
|
|
146
|
+
async def on_stop(self) -> None:
|
|
147
|
+
"""Stop the plugin - clean shutdown of MCP server."""
|
|
148
|
+
if self._mounted:
|
|
149
|
+
self.logger.info("MCP server shutting down")
|
|
150
|
+
self._mounted = False
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
Licensed under the MIT License.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from typing import Awaitable, Callable, Dict, Mapping, Optional, Union
|
|
9
|
+
|
|
10
|
+
from mcp.client.sse import sse_client
|
|
11
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
12
|
+
|
|
13
|
+
ValueOrFactory = Union[str, Callable[[], Union[str, Awaitable[str]]]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@asynccontextmanager
|
|
17
|
+
async def create_streamable_http_transport(
|
|
18
|
+
url: str,
|
|
19
|
+
headers: Optional[Mapping[str, ValueOrFactory]] = None,
|
|
20
|
+
):
|
|
21
|
+
"""Create a streamable HTTP transport for MCP communication."""
|
|
22
|
+
resolved_headers: Dict[str, str] = {}
|
|
23
|
+
if headers:
|
|
24
|
+
for key, value in headers.items():
|
|
25
|
+
if callable(value):
|
|
26
|
+
resolved_value = value()
|
|
27
|
+
if asyncio.iscoroutine(resolved_value):
|
|
28
|
+
resolved_value = await resolved_value
|
|
29
|
+
resolved_headers[key] = str(resolved_value)
|
|
30
|
+
else:
|
|
31
|
+
resolved_headers[key] = str(value)
|
|
32
|
+
|
|
33
|
+
async with streamablehttp_client(url, headers=resolved_headers) as (read_stream, write_stream, _):
|
|
34
|
+
yield read_stream, write_stream
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@asynccontextmanager
|
|
38
|
+
async def create_sse_transport(
|
|
39
|
+
url: str,
|
|
40
|
+
headers: Optional[Mapping[str, ValueOrFactory]] = None,
|
|
41
|
+
):
|
|
42
|
+
"""Create an SSE transport for MCP communication."""
|
|
43
|
+
resolved_headers: Dict[str, str] = {}
|
|
44
|
+
if headers:
|
|
45
|
+
for key, value in headers.items():
|
|
46
|
+
if callable(value):
|
|
47
|
+
resolved_value = value()
|
|
48
|
+
if asyncio.iscoroutine(resolved_value):
|
|
49
|
+
resolved_value = await resolved_value
|
|
50
|
+
resolved_headers[key] = str(resolved_value)
|
|
51
|
+
else:
|
|
52
|
+
resolved_headers[key] = str(value)
|
|
53
|
+
|
|
54
|
+
async with sse_client(url, headers=resolved_headers) as (read_stream, write_stream):
|
|
55
|
+
yield read_stream, write_stream
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_transport(
|
|
59
|
+
url: str,
|
|
60
|
+
transport_type: str = "streamable_http",
|
|
61
|
+
headers: Optional[Mapping[str, ValueOrFactory]] = None,
|
|
62
|
+
):
|
|
63
|
+
"""Create the appropriate transport based on transport type."""
|
|
64
|
+
if transport_type == "streamable_http":
|
|
65
|
+
return create_streamable_http_transport(url, headers)
|
|
66
|
+
elif transport_type == "sse":
|
|
67
|
+
return create_sse_transport(url, headers)
|
|
68
|
+
else:
|
|
69
|
+
raise ValueError(f"Unsupported transport type: {transport_type}")
|