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.
@@ -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}")