teams-transcripts 0.5.0__py3-none-any.whl
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.
- legacy/__init__.py +0 -0
- legacy/transcript_download.py +3969 -0
- teams_transcripts/__init__.py +1 -0
- teams_transcripts/__main__.py +288 -0
- teams_transcripts/adapters/__init__.py +1 -0
- teams_transcripts/adapters/cdp/__init__.py +1 -0
- teams_transcripts/adapters/cdp/browser.py +196 -0
- teams_transcripts/adapters/cdp/connection.py +168 -0
- teams_transcripts/adapters/cdp/helpers.py +449 -0
- teams_transcripts/adapters/cdp/teams.py +463 -0
- teams_transcripts/adapters/vertex.py +274 -0
- teams_transcripts/domain/__init__.py +1 -0
- teams_transcripts/domain/chat.py +115 -0
- teams_transcripts/domain/html.py +126 -0
- teams_transcripts/domain/mcps.py +343 -0
- teams_transcripts/domain/models.py +531 -0
- teams_transcripts/domain/participants.py +292 -0
- teams_transcripts/domain/scoring.py +106 -0
- teams_transcripts/domain/speakers.py +130 -0
- teams_transcripts/domain/summary.py +266 -0
- teams_transcripts/domain/timestamps.py +350 -0
- teams_transcripts/domain/vtt.py +444 -0
- teams_transcripts/infrastructure/__init__.py +1 -0
- teams_transcripts/infrastructure/config.py +987 -0
- teams_transcripts/infrastructure/errors.py +120 -0
- teams_transcripts/infrastructure/logging.py +69 -0
- teams_transcripts/infrastructure/signals.py +143 -0
- teams_transcripts/mcp_server.py +818 -0
- teams_transcripts/ports/__init__.py +1 -0
- teams_transcripts/ports/agent.py +58 -0
- teams_transcripts/ports/browser.py +126 -0
- teams_transcripts/services/__init__.py +1 -0
- teams_transcripts/services/downloader.py +1283 -0
- teams_transcripts/services/extraction.py +1603 -0
- teams_transcripts/services/listing.py +826 -0
- teams_transcripts/services/navigation.py +1721 -0
- teams_transcripts/services/output.py +84 -0
- teams_transcripts/services/tenant.py +684 -0
- teams_transcripts-0.5.0.dist-info/METADATA +1438 -0
- teams_transcripts-0.5.0.dist-info/RECORD +42 -0
- teams_transcripts-0.5.0.dist-info/WHEEL +4 -0
- teams_transcripts-0.5.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
"""FastMCP STDIO server exposing Teams transcript extraction as typed tools.
|
|
2
|
+
|
|
3
|
+
This module wraps the ``teams-transcripts`` CLI as MCP tools, callable
|
|
4
|
+
by any MCP client (Claude Desktop, Cursor, OpenCode, etc.) over STDIO.
|
|
5
|
+
|
|
6
|
+
Architecture:
|
|
7
|
+
Each tool builds a CLI command list, shells out via ``asyncio.subprocess``,
|
|
8
|
+
and returns the parsed JSON result. The subprocess approach provides:
|
|
9
|
+
- Process isolation (Teams CDP locks are per-process)
|
|
10
|
+
- Clean signal handling (the child handles its own SIGINT/SIGTERM)
|
|
11
|
+
- No import-time side effects in the MCP server process
|
|
12
|
+
|
|
13
|
+
Environment:
|
|
14
|
+
The MCP server inherits environment variables for GCP configuration:
|
|
15
|
+
- GOOGLE_CLOUD_PROJECT (required for download tools)
|
|
16
|
+
- CLOUD_ML_REGION (optional, default: global)
|
|
17
|
+
- MODEL_ID (optional, default: claude-haiku-4-5)
|
|
18
|
+
- GOOGLE_ADC (optional)
|
|
19
|
+
- TEAMS_TENANT (optional)
|
|
20
|
+
|
|
21
|
+
Usage::
|
|
22
|
+
|
|
23
|
+
# Start the MCP server (STDIO transport)
|
|
24
|
+
teams-transcripts-mcp
|
|
25
|
+
|
|
26
|
+
# Or run as a module
|
|
27
|
+
python -m teams_transcripts.mcp_server
|
|
28
|
+
|
|
29
|
+
Schema version: 3.0.0
|
|
30
|
+
Created: 2026-04-27
|
|
31
|
+
Updated: 2026-05-02
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import json
|
|
38
|
+
import sys
|
|
39
|
+
from uuid import UUID
|
|
40
|
+
|
|
41
|
+
from mcp.server.fastmcp import FastMCP
|
|
42
|
+
|
|
43
|
+
from teams_transcripts.adapters.cdp.teams import RestartBlockedError, restart_teams_process
|
|
44
|
+
from teams_transcripts.domain.models import DownloadInput
|
|
45
|
+
from teams_transcripts.infrastructure.logging import Logger
|
|
46
|
+
from teams_transcripts.infrastructure.signals import ShutdownCoordinator
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Server definition
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
mcp = FastMCP(
|
|
53
|
+
"teams-transcripts",
|
|
54
|
+
instructions=(
|
|
55
|
+
"Tools for extracting Microsoft Teams meeting transcripts, chat messages, "
|
|
56
|
+
"and participant metadata. Requires the Teams desktop app to be running "
|
|
57
|
+
"with CDP enabled on port 9333. All tools return structured JSON.\n\n"
|
|
58
|
+
"Prefer these MCP tools over direct CLI invocation: they provide typed "
|
|
59
|
+
"parameters and pre-validation before invoking the CLI.\n\n"
|
|
60
|
+
"Recommended workflow:\n"
|
|
61
|
+
"1. Call list_transcripts() to discover available meetings and their IDs\n"
|
|
62
|
+
"2. Call download(call_id=...) for recurring meetings (uniquely identifies "
|
|
63
|
+
"a specific occurrence), or download(meeting_id=UUID) for non-recurring "
|
|
64
|
+
"meetings -- the unified entry point that validates arguments before "
|
|
65
|
+
"launching the subprocess\n\n"
|
|
66
|
+
"Alternative (specialised) tools are available when you only need a "
|
|
67
|
+
"subset of data (e.g. download_transcript_by_meeting_id). All download "
|
|
68
|
+
"tools accept dry_run=True to verify a meeting can be found without "
|
|
69
|
+
"performing transcript download, chat extraction, or Details tracking.\n\n"
|
|
70
|
+
"Argument constraints (enforced by download() pre-validation):\n"
|
|
71
|
+
"- meeting_id, call_id, and meeting are mutually exclusive\n"
|
|
72
|
+
"- date and time are incompatible with meeting_id and call_id\n"
|
|
73
|
+
"- At least one of transcript/chat/metadata must be True, except "
|
|
74
|
+
"with dry_run=True for a pure meeting-exists check\n\n"
|
|
75
|
+
"For multi-tenant environments, call list_tenants() first to discover "
|
|
76
|
+
"available tenants, then pass the tenant domain to other tools.\n\n"
|
|
77
|
+
"Automatic Teams restarts fail closed when a live call is active or its "
|
|
78
|
+
"state cannot be determined. If a tool reports RESTART_BLOCKED, do not "
|
|
79
|
+
"retry destructively. restart_teams(force=True) may be used only after "
|
|
80
|
+
"the user explicitly requests interruption of a possible live call."
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Internal helpers
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _find_cli_executable() -> str:
|
|
91
|
+
"""Resolve the CLI executable path.
|
|
92
|
+
|
|
93
|
+
Prefers the installed entry point; falls back to ``python -m`` invocation.
|
|
94
|
+
"""
|
|
95
|
+
import shutil
|
|
96
|
+
|
|
97
|
+
exe = shutil.which("teams-transcripts")
|
|
98
|
+
if exe:
|
|
99
|
+
return exe
|
|
100
|
+
# Fallback: use the same Python interpreter to run the module
|
|
101
|
+
return ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _validate_port(port: int) -> None:
|
|
105
|
+
"""Validate a TCP port value for MCP tool inputs."""
|
|
106
|
+
if not 1 <= port <= 65535:
|
|
107
|
+
raise ValueError("port must be between 1 and 65535.")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _run_cli(args: list[str], timeout: int = 600) -> dict:
|
|
111
|
+
"""Execute the CLI subprocess and return parsed JSON output.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
args: CLI arguments (excluding the executable itself).
|
|
115
|
+
timeout: Maximum seconds to wait for the subprocess.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Parsed JSON dict from stdout.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
RuntimeError: If the subprocess fails or produces invalid JSON.
|
|
122
|
+
"""
|
|
123
|
+
exe = _find_cli_executable()
|
|
124
|
+
cmd = [exe, *args] if exe else [sys.executable, "-m", "teams_transcripts", *args]
|
|
125
|
+
|
|
126
|
+
proc = await asyncio.create_subprocess_exec(
|
|
127
|
+
*cmd,
|
|
128
|
+
stdout=asyncio.subprocess.PIPE,
|
|
129
|
+
stderr=asyncio.subprocess.PIPE,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
stdout, stderr = await asyncio.wait_for(
|
|
134
|
+
proc.communicate(),
|
|
135
|
+
timeout=timeout,
|
|
136
|
+
)
|
|
137
|
+
except asyncio.TimeoutError as exc:
|
|
138
|
+
proc.kill()
|
|
139
|
+
await proc.communicate()
|
|
140
|
+
raise RuntimeError(f"CLI timed out after {timeout}s. Args: {args}") from exc
|
|
141
|
+
|
|
142
|
+
stdout_text = stdout.decode("utf-8", errors="replace").strip()
|
|
143
|
+
stderr_text = stderr.decode("utf-8", errors="replace").strip()
|
|
144
|
+
|
|
145
|
+
if proc.returncode != 0:
|
|
146
|
+
# Try to parse error JSON from stdout (CLI emits structured errors)
|
|
147
|
+
if stdout_text:
|
|
148
|
+
try:
|
|
149
|
+
error_data = json.loads(stdout_text)
|
|
150
|
+
code = error_data.get("code", "UNKNOWN")
|
|
151
|
+
msg = error_data.get("message", stderr_text or "Unknown error")
|
|
152
|
+
raise RuntimeError(f"[{code}] {msg} (exit {proc.returncode})")
|
|
153
|
+
except json.JSONDecodeError:
|
|
154
|
+
pass
|
|
155
|
+
raise RuntimeError(
|
|
156
|
+
f"CLI failed (exit {proc.returncode}): {stderr_text or stdout_text or 'no output'}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if not stdout_text:
|
|
160
|
+
raise RuntimeError("CLI produced no output on stdout")
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
return json.loads(stdout_text)
|
|
164
|
+
except json.JSONDecodeError as e:
|
|
165
|
+
raise RuntimeError(
|
|
166
|
+
f"CLI produced invalid JSON: {e}. First 200 chars: {stdout_text[:200]}"
|
|
167
|
+
) from e
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def _run_cli_list(args: list[str], timeout: int = 120) -> list:
|
|
171
|
+
"""Execute the CLI subprocess and return parsed JSON list output.
|
|
172
|
+
|
|
173
|
+
Like ``_run_cli`` but expects a JSON array (used by list commands).
|
|
174
|
+
"""
|
|
175
|
+
exe = _find_cli_executable()
|
|
176
|
+
cmd = [exe, *args] if exe else [sys.executable, "-m", "teams_transcripts", *args]
|
|
177
|
+
|
|
178
|
+
proc = await asyncio.create_subprocess_exec(
|
|
179
|
+
*cmd,
|
|
180
|
+
stdout=asyncio.subprocess.PIPE,
|
|
181
|
+
stderr=asyncio.subprocess.PIPE,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
stdout, stderr = await asyncio.wait_for(
|
|
186
|
+
proc.communicate(),
|
|
187
|
+
timeout=timeout,
|
|
188
|
+
)
|
|
189
|
+
except asyncio.TimeoutError as exc:
|
|
190
|
+
proc.kill()
|
|
191
|
+
await proc.communicate()
|
|
192
|
+
raise RuntimeError(f"CLI timed out after {timeout}s. Args: {args}") from exc
|
|
193
|
+
|
|
194
|
+
stdout_text = stdout.decode("utf-8", errors="replace").strip()
|
|
195
|
+
stderr_text = stderr.decode("utf-8", errors="replace").strip()
|
|
196
|
+
|
|
197
|
+
if proc.returncode != 0:
|
|
198
|
+
if stdout_text:
|
|
199
|
+
try:
|
|
200
|
+
error_data = json.loads(stdout_text)
|
|
201
|
+
code = error_data.get("code", "UNKNOWN")
|
|
202
|
+
msg = error_data.get("message", stderr_text or "Unknown error")
|
|
203
|
+
raise RuntimeError(f"[{code}] {msg} (exit {proc.returncode})")
|
|
204
|
+
except json.JSONDecodeError:
|
|
205
|
+
pass
|
|
206
|
+
raise RuntimeError(
|
|
207
|
+
f"CLI failed (exit {proc.returncode}): {stderr_text or stdout_text or 'no output'}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if not stdout_text:
|
|
211
|
+
raise RuntimeError("CLI produced no output on stdout")
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
return json.loads(stdout_text)
|
|
215
|
+
except json.JSONDecodeError as e:
|
|
216
|
+
raise RuntimeError(
|
|
217
|
+
f"CLI produced invalid JSON: {e}. First 200 chars: {stdout_text[:200]}"
|
|
218
|
+
) from e
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _base_args(
|
|
222
|
+
meeting: str,
|
|
223
|
+
date: str,
|
|
224
|
+
*,
|
|
225
|
+
time: str | None = None,
|
|
226
|
+
tenant: str | None = None,
|
|
227
|
+
port: int = 9333,
|
|
228
|
+
dry_run: bool = False,
|
|
229
|
+
) -> list[str]:
|
|
230
|
+
"""Build common CLI arguments for meeting-download tools (name-based)."""
|
|
231
|
+
_validate_port(port)
|
|
232
|
+
args = [
|
|
233
|
+
"--meeting",
|
|
234
|
+
meeting,
|
|
235
|
+
"--datetime",
|
|
236
|
+
f"{date}T{time}" if time else date,
|
|
237
|
+
"--format",
|
|
238
|
+
"json",
|
|
239
|
+
"--quiet",
|
|
240
|
+
"--port",
|
|
241
|
+
str(port),
|
|
242
|
+
]
|
|
243
|
+
if tenant:
|
|
244
|
+
args.extend(["--tenant", tenant])
|
|
245
|
+
if dry_run:
|
|
246
|
+
args.append("--dry-run")
|
|
247
|
+
return args
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _base_args_by_id(
|
|
251
|
+
meeting_id: str,
|
|
252
|
+
*,
|
|
253
|
+
tenant: str | None = None,
|
|
254
|
+
port: int = 9333,
|
|
255
|
+
dry_run: bool = False,
|
|
256
|
+
) -> list[str]:
|
|
257
|
+
"""Build common CLI arguments for meeting-download tools (UUID-based)."""
|
|
258
|
+
meeting_id = str(UUID(meeting_id))
|
|
259
|
+
_validate_port(port)
|
|
260
|
+
args = [
|
|
261
|
+
"--meeting-id",
|
|
262
|
+
meeting_id,
|
|
263
|
+
"--format",
|
|
264
|
+
"json",
|
|
265
|
+
"--quiet",
|
|
266
|
+
"--port",
|
|
267
|
+
str(port),
|
|
268
|
+
]
|
|
269
|
+
if tenant:
|
|
270
|
+
args.extend(["--tenant", tenant])
|
|
271
|
+
if dry_run:
|
|
272
|
+
args.append("--dry-run")
|
|
273
|
+
return args
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _base_args_by_call_id(
|
|
277
|
+
call_id: str,
|
|
278
|
+
*,
|
|
279
|
+
tenant: str | None = None,
|
|
280
|
+
port: int = 9333,
|
|
281
|
+
dry_run: bool = False,
|
|
282
|
+
) -> list[str]:
|
|
283
|
+
"""Build common CLI arguments for meeting-download tools (call-ID-based)."""
|
|
284
|
+
if not call_id or not call_id.strip():
|
|
285
|
+
raise ValueError("call_id must be a non-empty string.")
|
|
286
|
+
_validate_port(port)
|
|
287
|
+
args = [
|
|
288
|
+
"--call-id",
|
|
289
|
+
call_id,
|
|
290
|
+
"--format",
|
|
291
|
+
"json",
|
|
292
|
+
"--quiet",
|
|
293
|
+
"--port",
|
|
294
|
+
str(port),
|
|
295
|
+
]
|
|
296
|
+
if tenant:
|
|
297
|
+
args.extend(["--tenant", tenant])
|
|
298
|
+
if dry_run:
|
|
299
|
+
args.append("--dry-run")
|
|
300
|
+
return args
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# MCP Tools -- Discovery
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@mcp.tool()
|
|
309
|
+
def restart_teams(port: int = 9333, force: bool = False) -> dict:
|
|
310
|
+
"""Safely restart Teams with CDP enabled and report readiness.
|
|
311
|
+
|
|
312
|
+
The restart is blocked when a live call is detected or call state cannot
|
|
313
|
+
be established. Agents must set ``force=True`` only after the user
|
|
314
|
+
explicitly requests interruption of a possible live call.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
port: CDP remote debugging port (default: 9333, range: 1..65535).
|
|
318
|
+
force: Bypass the call safeguard after explicit user approval.
|
|
319
|
+
"""
|
|
320
|
+
_validate_port(port)
|
|
321
|
+
logger = Logger(quiet=True)
|
|
322
|
+
shutdown = ShutdownCoordinator(format_json=True)
|
|
323
|
+
try:
|
|
324
|
+
result = restart_teams_process(
|
|
325
|
+
port,
|
|
326
|
+
logger=logger,
|
|
327
|
+
shutdown=shutdown,
|
|
328
|
+
force=force,
|
|
329
|
+
)
|
|
330
|
+
except RestartBlockedError as error:
|
|
331
|
+
return {
|
|
332
|
+
"status": "blocked",
|
|
333
|
+
"restarted": False,
|
|
334
|
+
"cdp_ready": False,
|
|
335
|
+
"port": port,
|
|
336
|
+
"call_state": error.call_state.value,
|
|
337
|
+
"reason": error.reason,
|
|
338
|
+
"forced": force,
|
|
339
|
+
}
|
|
340
|
+
response = result.to_dict()
|
|
341
|
+
if not result.cdp_ready:
|
|
342
|
+
response["reason"] = "cdp_timeout"
|
|
343
|
+
return response
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@mcp.tool()
|
|
347
|
+
async def list_tenants(
|
|
348
|
+
port: int = 9333,
|
|
349
|
+
) -> list:
|
|
350
|
+
"""List available Azure AD tenants the signed-in Teams user can access.
|
|
351
|
+
|
|
352
|
+
Returns an array of tenant objects with name, primary domain, tenant ID,
|
|
353
|
+
and whether it is the currently active tenant. Use a tenant domain value
|
|
354
|
+
with the 'tenant' parameter in other tools to ensure the correct tenant
|
|
355
|
+
is active.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
port: CDP remote debugging port (default: 9333, range: 1..65535).
|
|
359
|
+
"""
|
|
360
|
+
_validate_port(port)
|
|
361
|
+
args = ["--list-tenants", "--format", "json", "--quiet", "--port", str(port)]
|
|
362
|
+
return await _run_cli_list(args, timeout=120)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@mcp.tool()
|
|
366
|
+
async def list_transcripts(
|
|
367
|
+
days: int = 7,
|
|
368
|
+
tenant: str | None = None,
|
|
369
|
+
port: int = 9333,
|
|
370
|
+
) -> list:
|
|
371
|
+
"""List meetings with transcripts available for download.
|
|
372
|
+
|
|
373
|
+
Queries the Teams MCPS API to discover meetings that have transcripts
|
|
374
|
+
within the specified time range. Returns an array of meeting objects,
|
|
375
|
+
each containing subject, date, thread_id, has_transcript, has_recording,
|
|
376
|
+
and transcript/recording URLs.
|
|
377
|
+
|
|
378
|
+
The meeting ID (UUID) from each result can be passed to
|
|
379
|
+
download_by_meeting_id() for unambiguous transcript extraction.
|
|
380
|
+
|
|
381
|
+
Without a tenant specified, aggregates results across all signed-in tenants.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
days: Number of days to look back (default: 7, must be >= 1; max recommended: 90).
|
|
385
|
+
tenant: Azure AD tenant domain to query (e.g. 'contoso.com').
|
|
386
|
+
Without this, aggregates across all signed-in tenants.
|
|
387
|
+
port: CDP remote debugging port (default: 9333, range: 1..65535).
|
|
388
|
+
"""
|
|
389
|
+
if days < 1:
|
|
390
|
+
raise ValueError("days must be 1 or greater.")
|
|
391
|
+
_validate_port(port)
|
|
392
|
+
args = [
|
|
393
|
+
"--list-transcripts",
|
|
394
|
+
str(days),
|
|
395
|
+
"--format",
|
|
396
|
+
"json",
|
|
397
|
+
"--quiet",
|
|
398
|
+
"--port",
|
|
399
|
+
str(port),
|
|
400
|
+
]
|
|
401
|
+
if tenant:
|
|
402
|
+
args.extend(["--tenant", tenant])
|
|
403
|
+
return await _run_cli_list(args, timeout=120)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
# MCP Tools -- Unified download (recommended entry point)
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@mcp.tool()
|
|
412
|
+
async def download(
|
|
413
|
+
meeting_id: str | None = None,
|
|
414
|
+
call_id: str | None = None,
|
|
415
|
+
meeting: str | None = None,
|
|
416
|
+
date: str | None = None,
|
|
417
|
+
time: str | None = None,
|
|
418
|
+
transcript: bool = True,
|
|
419
|
+
chat: bool = True,
|
|
420
|
+
metadata: bool = True,
|
|
421
|
+
tenant: str | None = None,
|
|
422
|
+
port: int = 9333,
|
|
423
|
+
dry_run: bool = False,
|
|
424
|
+
) -> dict:
|
|
425
|
+
"""Unified download tool -- the recommended entry point for AI agents.
|
|
426
|
+
|
|
427
|
+
Accepts EITHER a call_id (best for recurring meetings), a meeting UUID,
|
|
428
|
+
OR a meeting name + date. Pre-validates argument combinations before
|
|
429
|
+
launching the CLI subprocess, giving immediate clear errors rather than
|
|
430
|
+
a delayed subprocess failure.
|
|
431
|
+
|
|
432
|
+
Prefer call_id for recurring meetings (uniquely identifies the specific
|
|
433
|
+
occurrence). Use meeting_id for non-recurring meetings. Fall back to
|
|
434
|
+
meeting + date when neither ID is available.
|
|
435
|
+
|
|
436
|
+
At least one of transcript, chat, or metadata must be True, except when
|
|
437
|
+
dry_run is True for a pure meeting-exists check.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
meeting_id: Meeting UUID (from list_transcripts ID column). Mutually
|
|
441
|
+
exclusive with call_id and meeting/date/time.
|
|
442
|
+
call_id: Call identifier (from list_transcripts Call ID column).
|
|
443
|
+
Uniquely identifies a specific occurrence; preferred for
|
|
444
|
+
recurring meetings. Mutually exclusive with meeting_id and
|
|
445
|
+
meeting/date/time.
|
|
446
|
+
meeting: Meeting name as it appears in Teams search results.
|
|
447
|
+
Required when neither meeting_id nor call_id is provided.
|
|
448
|
+
date: Meeting date in YYYY-MM-DD format. Required when using
|
|
449
|
+
meeting (defaults to today if omitted). Incompatible with
|
|
450
|
+
meeting_id and call_id (date is resolved automatically).
|
|
451
|
+
time: Optional start time in HH:MM format to disambiguate same-day
|
|
452
|
+
meetings. Only valid with meeting, not meeting_id or call_id.
|
|
453
|
+
transcript: Whether to download the transcript (default: True).
|
|
454
|
+
chat: Whether to download chat messages (default: True).
|
|
455
|
+
metadata: Whether to download participant metadata (default: True).
|
|
456
|
+
tenant: Azure AD tenant domain to verify/switch to (e.g. 'contoso.com').
|
|
457
|
+
port: CDP remote debugging port (default: 9333, range: 1..65535).
|
|
458
|
+
dry_run: If True, verify navigation and requested safe metadata but skip
|
|
459
|
+
transcript download, chat/attendance extraction, and Details tracking.
|
|
460
|
+
Allows transcript=False, chat=False, metadata=False.
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Structured JSON with the requested data (transcript cues, chat
|
|
464
|
+
messages, and/or participant metadata).
|
|
465
|
+
|
|
466
|
+
Raises:
|
|
467
|
+
ValueError: If argument combinations are invalid.
|
|
468
|
+
"""
|
|
469
|
+
# -- Pre-subprocess validation via Pydantic model -----------------------
|
|
470
|
+
try:
|
|
471
|
+
DownloadInput(
|
|
472
|
+
meeting_id=meeting_id,
|
|
473
|
+
call_id=call_id,
|
|
474
|
+
meeting=meeting,
|
|
475
|
+
date=date,
|
|
476
|
+
time=time,
|
|
477
|
+
include_transcript=transcript,
|
|
478
|
+
include_chat=chat,
|
|
479
|
+
include_metadata=metadata,
|
|
480
|
+
dry_run=dry_run,
|
|
481
|
+
)
|
|
482
|
+
except Exception as exc:
|
|
483
|
+
# Re-raise as ValueError for a stable MCP interface -- FastMCP
|
|
484
|
+
# converts ValueError to a structured error response.
|
|
485
|
+
raise ValueError(str(exc)) from exc
|
|
486
|
+
|
|
487
|
+
# -- Build CLI args -----------------------------------------------------
|
|
488
|
+
_yes_no = lambda b: "yes" if b else "no" # noqa: E731
|
|
489
|
+
|
|
490
|
+
if call_id:
|
|
491
|
+
args = _base_args_by_call_id(
|
|
492
|
+
call_id,
|
|
493
|
+
tenant=tenant,
|
|
494
|
+
port=port,
|
|
495
|
+
dry_run=dry_run,
|
|
496
|
+
)
|
|
497
|
+
elif meeting_id:
|
|
498
|
+
args = _base_args_by_id(
|
|
499
|
+
meeting_id,
|
|
500
|
+
tenant=tenant,
|
|
501
|
+
port=port,
|
|
502
|
+
dry_run=dry_run,
|
|
503
|
+
)
|
|
504
|
+
else:
|
|
505
|
+
assert meeting is not None # validated above
|
|
506
|
+
args = _base_args(
|
|
507
|
+
meeting,
|
|
508
|
+
date or "",
|
|
509
|
+
time=time,
|
|
510
|
+
tenant=tenant,
|
|
511
|
+
port=port,
|
|
512
|
+
dry_run=dry_run,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
args.extend(
|
|
516
|
+
[
|
|
517
|
+
"--transcript",
|
|
518
|
+
_yes_no(transcript),
|
|
519
|
+
"--chat",
|
|
520
|
+
_yes_no(chat),
|
|
521
|
+
"--metadata",
|
|
522
|
+
_yes_no(metadata),
|
|
523
|
+
]
|
|
524
|
+
)
|
|
525
|
+
return await _run_cli(args, timeout=600)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# ---------------------------------------------------------------------------
|
|
529
|
+
# MCP Tools -- Download by meeting ID (UUID)
|
|
530
|
+
# ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
@mcp.tool()
|
|
534
|
+
async def download_by_meeting_id(
|
|
535
|
+
meeting_id: str,
|
|
536
|
+
tenant: str | None = None,
|
|
537
|
+
port: int = 9333,
|
|
538
|
+
dry_run: bool = False,
|
|
539
|
+
) -> dict:
|
|
540
|
+
"""Download transcript, chat, and metadata by meeting UUID.
|
|
541
|
+
|
|
542
|
+
This is the preferred download method when you have a meeting UUID
|
|
543
|
+
(from list_transcripts output). It eliminates ambiguity when multiple
|
|
544
|
+
meetings share the same name. The meeting subject and date are resolved
|
|
545
|
+
automatically from the UUID via the MCPS API.
|
|
546
|
+
|
|
547
|
+
Returns the same structured JSON as download_all (transcript cues,
|
|
548
|
+
chat messages, and participant metadata).
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
meeting_id: Meeting UUID (e.g. '2b4c1f5c-636d-4d78-a087-c2cbf3887fd8').
|
|
552
|
+
Obtain this from list_transcripts() output.
|
|
553
|
+
tenant: Azure AD tenant domain to verify/switch to (e.g. 'contoso.com').
|
|
554
|
+
port: CDP remote debugging port (default: 9333, range: 1..65535).
|
|
555
|
+
dry_run: If True, verify the meeting can be found but skip download.
|
|
556
|
+
"""
|
|
557
|
+
args = _base_args_by_id(meeting_id, tenant=tenant, port=port, dry_run=dry_run)
|
|
558
|
+
args.extend(["--transcript", "yes", "--chat", "yes", "--metadata", "yes"])
|
|
559
|
+
return await _run_cli(args, timeout=600)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@mcp.tool()
|
|
563
|
+
async def download_by_call_id(
|
|
564
|
+
call_id: str,
|
|
565
|
+
tenant: str | None = None,
|
|
566
|
+
port: int = 9333,
|
|
567
|
+
dry_run: bool = False,
|
|
568
|
+
) -> dict:
|
|
569
|
+
"""Download transcript, chat, and metadata by call ID.
|
|
570
|
+
|
|
571
|
+
This is the preferred download method for recurring meetings. The
|
|
572
|
+
call_id uniquely identifies a specific occurrence, eliminating the
|
|
573
|
+
ambiguity that arises when multiple occurrences share the same
|
|
574
|
+
meeting UUID (thread_id).
|
|
575
|
+
|
|
576
|
+
The meeting subject, date, and thread are resolved automatically
|
|
577
|
+
from the call ID via the MCPS API.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
call_id: Call identifier (from list_transcripts() Call ID field).
|
|
581
|
+
Uniquely identifies a single meeting occurrence.
|
|
582
|
+
tenant: Azure AD tenant domain to verify/switch to (e.g. 'contoso.com').
|
|
583
|
+
port: CDP remote debugging port (default: 9333, range: 1..65535).
|
|
584
|
+
dry_run: If True, verify the meeting can be found but skip download.
|
|
585
|
+
"""
|
|
586
|
+
args = _base_args_by_call_id(call_id, tenant=tenant, port=port, dry_run=dry_run)
|
|
587
|
+
args.extend(["--transcript", "yes", "--chat", "yes", "--metadata", "yes"])
|
|
588
|
+
return await _run_cli(args, timeout=600)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@mcp.tool()
|
|
592
|
+
async def download_transcript_by_meeting_id(
|
|
593
|
+
meeting_id: str,
|
|
594
|
+
tenant: str | None = None,
|
|
595
|
+
port: int = 9333,
|
|
596
|
+
dry_run: bool = False,
|
|
597
|
+
) -> dict:
|
|
598
|
+
"""Download only the transcript (WebVTT cues) by meeting UUID.
|
|
599
|
+
|
|
600
|
+
Skips chat and metadata extraction for faster results. The meeting
|
|
601
|
+
subject and date are resolved automatically from the UUID.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
meeting_id: Meeting UUID (from list_transcripts output).
|
|
605
|
+
tenant: Azure AD tenant domain to verify/switch to (e.g. 'contoso.com').
|
|
606
|
+
port: CDP remote debugging port (default: 9333, range: 1..65535).
|
|
607
|
+
dry_run: If True, verify the meeting can be found but skip download.
|
|
608
|
+
"""
|
|
609
|
+
args = _base_args_by_id(meeting_id, tenant=tenant, port=port, dry_run=dry_run)
|
|
610
|
+
args.extend(["--transcript", "yes", "--chat", "no", "--metadata", "no"])
|
|
611
|
+
return await _run_cli(args, timeout=600)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@mcp.tool()
|
|
615
|
+
async def download_chat_by_meeting_id(
|
|
616
|
+
meeting_id: str,
|
|
617
|
+
tenant: str | None = None,
|
|
618
|
+
port: int = 9333,
|
|
619
|
+
dry_run: bool = False,
|
|
620
|
+
) -> dict:
|
|
621
|
+
"""Download only chat messages by meeting UUID.
|
|
622
|
+
|
|
623
|
+
Extracts all chat messages with timestamps, authors, text content,
|
|
624
|
+
and reactions. Skips transcript download and participant metadata.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
meeting_id: Meeting UUID (from list_transcripts output).
|
|
628
|
+
tenant: Azure AD tenant domain to verify/switch to (e.g. 'contoso.com').
|
|
629
|
+
port: CDP remote debugging port (default: 9333, range: 1..65535).
|
|
630
|
+
dry_run: If True, verify the meeting can be found but skip download.
|
|
631
|
+
"""
|
|
632
|
+
args = _base_args_by_id(meeting_id, tenant=tenant, port=port, dry_run=dry_run)
|
|
633
|
+
args.extend(["--transcript", "no", "--chat", "yes", "--metadata", "no"])
|
|
634
|
+
return await _run_cli(args, timeout=600)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
@mcp.tool()
|
|
638
|
+
async def download_metadata_by_meeting_id(
|
|
639
|
+
meeting_id: str,
|
|
640
|
+
tenant: str | None = None,
|
|
641
|
+
port: int = 9333,
|
|
642
|
+
dry_run: bool = False,
|
|
643
|
+
) -> dict:
|
|
644
|
+
"""Download only participant metadata by meeting UUID.
|
|
645
|
+
|
|
646
|
+
Extracts categorised participant lists (organiser, invited with RSVP
|
|
647
|
+
status, speakers, attendees). Skips transcript and chat extraction.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
meeting_id: Meeting UUID (from list_transcripts output).
|
|
651
|
+
tenant: Azure AD tenant domain to verify/switch to (e.g. 'contoso.com').
|
|
652
|
+
port: CDP remote debugging port (default: 9333, range: 1..65535).
|
|
653
|
+
dry_run: If True, verify the meeting can be found but skip download.
|
|
654
|
+
"""
|
|
655
|
+
args = _base_args_by_id(meeting_id, tenant=tenant, port=port, dry_run=dry_run)
|
|
656
|
+
args.extend(["--transcript", "no", "--chat", "no", "--metadata", "yes"])
|
|
657
|
+
return await _run_cli(args, timeout=600)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# ---------------------------------------------------------------------------
|
|
661
|
+
# MCP Tools -- Download by meeting name + date
|
|
662
|
+
# ---------------------------------------------------------------------------
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@mcp.tool()
|
|
666
|
+
async def download_all(
|
|
667
|
+
meeting: str,
|
|
668
|
+
date: str,
|
|
669
|
+
time: str | None = None,
|
|
670
|
+
tenant: str | None = None,
|
|
671
|
+
port: int = 9333,
|
|
672
|
+
dry_run: bool = False,
|
|
673
|
+
) -> dict:
|
|
674
|
+
"""Download transcript, chat messages, and participant metadata for a meeting.
|
|
675
|
+
|
|
676
|
+
This is the most comprehensive extraction tool. It downloads the full
|
|
677
|
+
transcript (WebVTT cues), all chat messages with timestamps and reactions,
|
|
678
|
+
and categorised participant lists (organiser, invited, speakers, attendees)
|
|
679
|
+
with RSVP status.
|
|
680
|
+
|
|
681
|
+
Use this when you know the meeting name and date. For unambiguous lookup
|
|
682
|
+
by UUID, use download_by_meeting_id() instead.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
meeting: Meeting name as it appears in Teams search results.
|
|
686
|
+
date: Meeting date in YYYY-MM-DD format.
|
|
687
|
+
time: Optional start time in HH:MM format to disambiguate same-day meetings.
|
|
688
|
+
tenant: Azure AD tenant domain to verify/switch to (e.g. 'contoso.com').
|
|
689
|
+
port: CDP remote debugging port (default: 9333, range: 1..65535).
|
|
690
|
+
dry_run: If True, verify the meeting can be found but skip download.
|
|
691
|
+
"""
|
|
692
|
+
args = _base_args(meeting, date, time=time, tenant=tenant, port=port, dry_run=dry_run)
|
|
693
|
+
args.extend(["--transcript", "yes", "--chat", "yes", "--metadata", "yes"])
|
|
694
|
+
return await _run_cli(args, timeout=600)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@mcp.tool()
|
|
698
|
+
async def download_transcript(
|
|
699
|
+
meeting: str,
|
|
700
|
+
date: str,
|
|
701
|
+
time: str | None = None,
|
|
702
|
+
tenant: str | None = None,
|
|
703
|
+
port: int = 9333,
|
|
704
|
+
dry_run: bool = False,
|
|
705
|
+
) -> dict:
|
|
706
|
+
"""Download only the transcript (WebVTT cues) for a meeting.
|
|
707
|
+
|
|
708
|
+
Skips chat and metadata extraction for faster results. The returned
|
|
709
|
+
JSON includes structured transcript cue objects, speaker breakdown, and
|
|
710
|
+
cue count.
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
meeting: Meeting name as it appears in Teams search results.
|
|
714
|
+
date: Meeting date in YYYY-MM-DD format.
|
|
715
|
+
time: Optional start time in HH:MM format to disambiguate same-day meetings.
|
|
716
|
+
tenant: Azure AD tenant domain to verify/switch to (e.g. 'contoso.com').
|
|
717
|
+
port: CDP remote debugging port (default: 9333, range: 1..65535).
|
|
718
|
+
dry_run: If True, verify the meeting can be found but skip download.
|
|
719
|
+
"""
|
|
720
|
+
args = _base_args(meeting, date, time=time, tenant=tenant, port=port, dry_run=dry_run)
|
|
721
|
+
args.extend(["--transcript", "yes", "--chat", "no", "--metadata", "no"])
|
|
722
|
+
return await _run_cli(args, timeout=600)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
@mcp.tool()
|
|
726
|
+
async def download_chat(
|
|
727
|
+
meeting: str,
|
|
728
|
+
date: str,
|
|
729
|
+
time: str | None = None,
|
|
730
|
+
tenant: str | None = None,
|
|
731
|
+
port: int = 9333,
|
|
732
|
+
dry_run: bool = False,
|
|
733
|
+
) -> dict:
|
|
734
|
+
"""Download only chat messages from a meeting.
|
|
735
|
+
|
|
736
|
+
Extracts all chat messages with timestamps, authors, text content,
|
|
737
|
+
and reactions. Each message includes an offset from the meeting start
|
|
738
|
+
time. Skips transcript download and participant metadata.
|
|
739
|
+
|
|
740
|
+
Args:
|
|
741
|
+
meeting: Meeting name as it appears in Teams search results.
|
|
742
|
+
date: Meeting date in YYYY-MM-DD format.
|
|
743
|
+
time: Optional start time in HH:MM format to disambiguate same-day meetings.
|
|
744
|
+
tenant: Azure AD tenant domain to verify/switch to (e.g. 'contoso.com').
|
|
745
|
+
port: CDP remote debugging port (default: 9333, range: 1..65535).
|
|
746
|
+
dry_run: If True, verify the meeting can be found but skip download.
|
|
747
|
+
"""
|
|
748
|
+
args = _base_args(meeting, date, time=time, tenant=tenant, port=port, dry_run=dry_run)
|
|
749
|
+
args.extend(["--transcript", "no", "--chat", "yes", "--metadata", "no"])
|
|
750
|
+
return await _run_cli(args, timeout=600)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
@mcp.tool()
|
|
754
|
+
async def download_meeting_metadata(
|
|
755
|
+
meeting: str,
|
|
756
|
+
date: str,
|
|
757
|
+
time: str | None = None,
|
|
758
|
+
tenant: str | None = None,
|
|
759
|
+
port: int = 9333,
|
|
760
|
+
dry_run: bool = False,
|
|
761
|
+
) -> dict:
|
|
762
|
+
"""Download only participant metadata for a meeting.
|
|
763
|
+
|
|
764
|
+
Extracts categorised participant lists:
|
|
765
|
+
- Organiser: meeting creator with email
|
|
766
|
+
- Invited: all invited members with RSVP status (accepted/tentative/declined)
|
|
767
|
+
- Speakers: members who spoke during the meeting
|
|
768
|
+
- Attendees: members who joined the call
|
|
769
|
+
|
|
770
|
+
Skips transcript download and chat extraction.
|
|
771
|
+
|
|
772
|
+
Args:
|
|
773
|
+
meeting: Meeting name as it appears in Teams search results.
|
|
774
|
+
date: Meeting date in YYYY-MM-DD format.
|
|
775
|
+
time: Optional start time in HH:MM format to disambiguate same-day meetings.
|
|
776
|
+
tenant: Azure AD tenant domain to verify/switch to (e.g. 'contoso.com').
|
|
777
|
+
port: CDP remote debugging port (default: 9333, range: 1..65535).
|
|
778
|
+
dry_run: If True, verify the meeting can be found but skip download.
|
|
779
|
+
"""
|
|
780
|
+
args = _base_args(meeting, date, time=time, tenant=tenant, port=port, dry_run=dry_run)
|
|
781
|
+
args.extend(["--transcript", "no", "--chat", "no", "--metadata", "yes"])
|
|
782
|
+
return await _run_cli(args, timeout=600)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
# ---------------------------------------------------------------------------
|
|
786
|
+
# Entry point
|
|
787
|
+
# ---------------------------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def main() -> None:
|
|
791
|
+
"""Run the MCP server.
|
|
792
|
+
|
|
793
|
+
Supports STDIO (default) or SSE transport when --port is specified.
|
|
794
|
+
"""
|
|
795
|
+
import argparse
|
|
796
|
+
|
|
797
|
+
p = argparse.ArgumentParser(
|
|
798
|
+
description="Teams Transcripts MCP server",
|
|
799
|
+
)
|
|
800
|
+
p.add_argument(
|
|
801
|
+
"--port",
|
|
802
|
+
type=int,
|
|
803
|
+
default=None,
|
|
804
|
+
metavar="PORT",
|
|
805
|
+
help="Run as an SSE server on this port instead of STDIO.",
|
|
806
|
+
)
|
|
807
|
+
args = p.parse_args()
|
|
808
|
+
|
|
809
|
+
if args.port:
|
|
810
|
+
_validate_port(args.port)
|
|
811
|
+
mcp.settings.port = args.port
|
|
812
|
+
mcp.run(transport="sse")
|
|
813
|
+
else:
|
|
814
|
+
mcp.run(transport="stdio")
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
if __name__ == "__main__":
|
|
818
|
+
main()
|