z230
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
"""Smoke test — spustí stejný launch jako mcp_owa, drží 8 s, zavře."""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
PROFILE = Path(__file__).resolve().parent / "outlook_profile"
|
||||
|
||||
async def main():
|
||||
pw = await async_playwright().start()
|
||||
ctx = await pw.chromium.launch_persistent_context(
|
||||
user_data_dir=str(PROFILE),
|
||||
headless=False,
|
||||
no_viewport=True,
|
||||
args=["--disable-blink-features=AutomationControlled", "--start-maximized"],
|
||||
)
|
||||
page = ctx.pages[0] if ctx.pages else await ctx.new_page()
|
||||
await page.goto("https://outlook.cloud.microsoft/mail/")
|
||||
print("OK, page open:", page.url)
|
||||
await asyncio.sleep(8)
|
||||
await ctx.close()
|
||||
await pw.stop()
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,68 @@
|
||||
# mcp_owa_v1.0
|
||||
|
||||
MCP server pro OWA (outlook.cloud.microsoft) — drží persistentní Playwright session a umožňuje claude-cowork:
|
||||
- vyhledat email v MongoDB `OperativniEmailyJNJ.messages`,
|
||||
- otevřít ho v OWA UI,
|
||||
- udělat Forward, vepsat úvodní text na začátek těla,
|
||||
- zavřít původní čtecí panel.
|
||||
|
||||
Odeslání forwardu **dělá uživatel ručně** v okně, které zůstává otevřené.
|
||||
|
||||
## Spuštění
|
||||
|
||||
Profil sdílí s `import_emails_to_mongo_v1.0.py` (`outlook_profile/`). Pokud profil neexistuje, spusť nejdřív `outlook_login_v1.0.py`.
|
||||
|
||||
Registrace v `.mcp.json` (stdio):
|
||||
```json
|
||||
"owa": {
|
||||
"command": "U:\\PythonProject\\Janssen\\.venv\\Scripts\\python.exe",
|
||||
"args": ["U:\\PythonProject\\Janssen\\Outlook\\mcp_owa_v1.0.py"]
|
||||
}
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Účel |
|
||||
|------|------|
|
||||
| `start_owa` | Spustí Playwright + otevře OWA |
|
||||
| `stop_owa` | Zavře okno |
|
||||
| `status` | Stav session |
|
||||
| `find_emails(query, from_email, folder, since_iso, limit)` | Hledání v MongoDB |
|
||||
| `find_last_email(from_email, folder)` | Nejnovější email |
|
||||
| `open_email_by_subject(subject)` | Otevře v OWA přes search |
|
||||
| `forward_current(body_prefix, subject_prefix)` | Ctrl+Shift+F, předvyplní |
|
||||
| `write_at_top(text)` | Vepíše text na začátek body draftu |
|
||||
| `set_recipients(to, cc)` | Doplní To/Cc |
|
||||
| `close_reading_pane` | Escape (zavře čtecí panel) |
|
||||
| `screenshot(path)` | Diagnostický screenshot |
|
||||
|
||||
## Typický flow
|
||||
|
||||
```
|
||||
start_owa
|
||||
find_last_email(from_email="...") → vrátí subject + metadata
|
||||
open_email_by_subject("<subject>") → otevře v reading pane
|
||||
forward_current(body_prefix="Posílám dále, prosím o vyjádření.\n")
|
||||
→ Ctrl+Shift+F, vepíše úvod
|
||||
set_recipients(to=["adresat@..."]) → vyplní příjemce
|
||||
→ uživatel zkontroluje a odešle ručně
|
||||
close_reading_pane (až po odeslání)
|
||||
```
|
||||
|
||||
## Poznámky / známá omezení
|
||||
|
||||
- **Forward draft = stejný tab** jako reading pane (inline composer). `close_reading_pane`
|
||||
proto NEZAVŘE draft, ale Escape může composer minimalizovat — preferuj zavírat
|
||||
až po odeslání.
|
||||
- **Podpis** se vkládá automaticky podle nastavení OWA — skript ho nevkládá.
|
||||
- **Subject prefix** — OWA si sám předřadí `FW: `, `subject_prefix` se přidá před to.
|
||||
- **Hledání emailu v UI** je přes search bar (subject substring). Pokud má více
|
||||
výsledků se stejným subjectem, otevře první (nejnovější).
|
||||
- Pokud OWA změní lokalizaci aria-labelů, uprav selektory v `forward_current` /
|
||||
`write_at_top` / `set_recipients`.
|
||||
|
||||
## TODO pro další verzi
|
||||
- Otevření přímo z `message_id` (přes search `messageid:`)
|
||||
- Detekce, zda forward composer otevřel popup tab místo inline
|
||||
- `send_forward()` tool s explicitním potvrzením
|
||||
- Volba složky před `open_email_by_subject`
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
=======================================================================
|
||||
Název: mcp_owa_v1.0.py
|
||||
Verze: 1.0
|
||||
Datum: 2026-06-04
|
||||
Popis: MCP server pro práci s otevřeným OWA oknem (Playwright).
|
||||
Drží persistent session a vystavuje tooly:
|
||||
- vyhledání emailu v MongoDB OperativniEmailyJNJ.messages
|
||||
- otevření emailu v OWA UI přes search
|
||||
- Forward → vepsání úvodního textu na začátek body
|
||||
- zavření původního čtecího panelu
|
||||
Odeslání forwardu dělá uživatel sám.
|
||||
Profil sdílí s import_emails_to_mongo_v1.0.py (outlook_profile/).
|
||||
Spuštění: python mcp_owa_v1.0.py (registrace přes .mcp.json)
|
||||
=======================================================================
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from playwright.async_api import async_playwright, Page, BrowserContext
|
||||
from pymongo import MongoClient, DESCENDING
|
||||
|
||||
# ── Konfigurace ────────────────────────────────────────────────────────
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
PROFILE_DIR = BASE_DIR / "outlook_profile"
|
||||
START_URL = "https://outlook.cloud.microsoft/mail/"
|
||||
|
||||
MONGO_URI = "mongodb://192.168.1.76:27017"
|
||||
DB_NAME = "OperativniEmailyJNJ"
|
||||
COL_NAME = "messages"
|
||||
|
||||
SEARCH_READY = (
|
||||
'[placeholder*="Search"], [aria-label*="Search"], '
|
||||
'[placeholder*="Hledat"], [aria-label*="Hledat"]'
|
||||
)
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
# ── Globální stav (persistent session) ─────────────────────────────────
|
||||
class State:
|
||||
pw = None # playwright instance
|
||||
context: Optional[BrowserContext] = None
|
||||
main_page: Optional[Page] = None
|
||||
draft_page: Optional[Page] = None # forward composer (může být totéž okno)
|
||||
lock = asyncio.Lock()
|
||||
|
||||
|
||||
_mongo = MongoClient(MONGO_URI)
|
||||
_col = _mongo[DB_NAME][COL_NAME]
|
||||
|
||||
mcp = FastMCP("owa")
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _doc_summary(doc: dict) -> dict:
|
||||
return {
|
||||
"message_id": doc.get("message_id"),
|
||||
"subject": doc.get("subject"),
|
||||
"from": doc.get("from"),
|
||||
"to": doc.get("to", [])[:5],
|
||||
"date": doc.get("date").isoformat() if doc.get("date") else None,
|
||||
"folder": doc.get("folder"),
|
||||
"has_attachments": doc.get("has_attachments", False),
|
||||
}
|
||||
|
||||
|
||||
async def _ensure_started():
|
||||
if State.context is None:
|
||||
raise RuntimeError("OWA není spuštěné. Zavolej nejprve start_owa.")
|
||||
|
||||
|
||||
async def _wait_ready(page: Page):
|
||||
await page.wait_for_load_state("domcontentloaded")
|
||||
await page.wait_for_selector(SEARCH_READY, timeout=30_000)
|
||||
|
||||
|
||||
# ── Tools: lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def start_owa() -> dict:
|
||||
"""Spustí Playwright s persistent profilem a otevře OWA. Okno zůstane otevřené."""
|
||||
async with State.lock:
|
||||
if State.context is not None:
|
||||
return {"status": "already_running", "url": State.main_page.url if State.main_page else None}
|
||||
|
||||
if not PROFILE_DIR.exists():
|
||||
return {"status": "error", "error": f"Profil nenalezen: {PROFILE_DIR}. Spusť outlook_login_v1.0.py."}
|
||||
|
||||
State.pw = await async_playwright().start()
|
||||
State.context = await State.pw.chromium.launch_persistent_context(
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
headless=False,
|
||||
no_viewport=True,
|
||||
accept_downloads=True,
|
||||
args=[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--start-maximized",
|
||||
],
|
||||
)
|
||||
State.main_page = State.context.pages[0] if State.context.pages else await State.context.new_page()
|
||||
await State.main_page.goto(START_URL)
|
||||
await _wait_ready(State.main_page)
|
||||
return {"status": "started", "url": State.main_page.url}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def stop_owa() -> dict:
|
||||
"""Zavře Playwright context (a tím i okno OWA)."""
|
||||
async with State.lock:
|
||||
if State.context is None:
|
||||
return {"status": "not_running"}
|
||||
try:
|
||||
await State.context.close()
|
||||
finally:
|
||||
if State.pw:
|
||||
await State.pw.stop()
|
||||
State.context = None
|
||||
State.main_page = None
|
||||
State.draft_page = None
|
||||
State.pw = None
|
||||
return {"status": "stopped"}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def status() -> dict:
|
||||
"""Vrátí stav session: běží/neběží, URL, počet tabů, draft otevřen?"""
|
||||
if State.context is None:
|
||||
return {"running": False}
|
||||
return {
|
||||
"running": True,
|
||||
"url": State.main_page.url if State.main_page else None,
|
||||
"tabs": len(State.context.pages),
|
||||
"draft_open": State.draft_page is not None and not State.draft_page.is_closed(),
|
||||
}
|
||||
|
||||
|
||||
# ── Tools: MongoDB lookup ──────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def find_emails(
|
||||
query: Optional[str] = None,
|
||||
from_email: Optional[str] = None,
|
||||
folder: Optional[str] = None,
|
||||
since_iso: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""Hledá v MongoDB. `query` = substring v subjectu (case-insensitive).
|
||||
`since_iso` = ISO datum, vrátí jen emaily od něj. Seřazeno od nejnovějšího."""
|
||||
flt: dict = {}
|
||||
if query:
|
||||
flt["subject"] = {"$regex": query, "$options": "i"}
|
||||
if from_email:
|
||||
flt["from.email"] = {"$regex": from_email, "$options": "i"}
|
||||
if folder:
|
||||
flt["folder"] = folder
|
||||
if since_iso:
|
||||
try:
|
||||
dt = datetime.fromisoformat(since_iso.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
flt["date"] = {"$gte": dt}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur = _col.find(flt).sort("date", DESCENDING).limit(max(1, min(limit, 50)))
|
||||
return [_doc_summary(d) for d in cur]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def find_last_email(from_email: Optional[str] = None, folder: Optional[str] = None) -> Optional[dict]:
|
||||
"""Vrátí nejnovější email (volitelně filtr podle odesílatele / složky)."""
|
||||
flt: dict = {}
|
||||
if from_email:
|
||||
flt["from.email"] = {"$regex": from_email, "$options": "i"}
|
||||
if folder:
|
||||
flt["folder"] = folder
|
||||
doc = _col.find(flt).sort("date", DESCENDING).limit(1)
|
||||
docs = list(doc)
|
||||
return _doc_summary(docs[0]) if docs else None
|
||||
|
||||
|
||||
# ── Tools: OWA UI ──────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def open_email_by_subject(subject: str) -> dict:
|
||||
"""Vyhledá v OWA podle subjectu a otevře první výsledek v reading pane."""
|
||||
await _ensure_started()
|
||||
page = State.main_page
|
||||
|
||||
search = page.locator(SEARCH_READY).first
|
||||
await search.click()
|
||||
await search.fill("")
|
||||
await search.type(subject, delay=20)
|
||||
await page.keyboard.press("Enter")
|
||||
await page.wait_for_timeout(2_000)
|
||||
|
||||
# první výsledek v listu
|
||||
msgs = page.locator('div[role="option"]')
|
||||
try:
|
||||
await msgs.first.wait_for(state="visible", timeout=10_000)
|
||||
except Exception:
|
||||
return {"status": "no_results"}
|
||||
await msgs.first.click()
|
||||
await page.wait_for_timeout(800)
|
||||
return {"status": "opened", "count_visible": await msgs.count()}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def forward_current(body_prefix: str = "", subject_prefix: str = "") -> dict:
|
||||
"""Klikne Forward na otevřeném emailu. Pokud je `body_prefix`, vepíše ho na začátek body
|
||||
(Home → text → Enter). Pokud `subject_prefix`, předřadí ho do předmětu draftu."""
|
||||
await _ensure_started()
|
||||
page = State.main_page
|
||||
|
||||
# Forward přes klávesovou zkratku (funguje, když je reading pane focus)
|
||||
await page.keyboard.press("Control+Shift+F")
|
||||
await page.wait_for_timeout(1_500)
|
||||
|
||||
# Body editor
|
||||
body = page.locator('div[aria-label*="Message body" i][contenteditable="true"], '
|
||||
'div[aria-label*="Tělo zprávy" i][contenteditable="true"]').first
|
||||
try:
|
||||
await body.wait_for(state="visible", timeout=10_000)
|
||||
except Exception:
|
||||
return {"status": "forward_failed", "hint": "Body editor forwardu nenalezen."}
|
||||
|
||||
State.draft_page = page # OWA forward bývá v tom samém tabu (inline composer)
|
||||
|
||||
if subject_prefix:
|
||||
subj = page.locator('input[aria-label*="subject" i], input[aria-label*="předmět" i]').first
|
||||
try:
|
||||
await subj.wait_for(state="visible", timeout=5_000)
|
||||
current = await subj.input_value()
|
||||
await subj.fill(f"{subject_prefix}{current}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if body_prefix:
|
||||
await body.click()
|
||||
await page.keyboard.press("Control+Home")
|
||||
await page.keyboard.type(body_prefix, delay=10)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
return {"status": "forward_ready"}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def write_at_top(text: str) -> dict:
|
||||
"""Vepíše text na začátek body otevřeného draftu (před existující obsah / podpis)."""
|
||||
await _ensure_started()
|
||||
if State.draft_page is None or State.draft_page.is_closed():
|
||||
return {"status": "no_draft"}
|
||||
page = State.draft_page
|
||||
body = page.locator('div[aria-label*="Message body" i][contenteditable="true"], '
|
||||
'div[aria-label*="Tělo zprávy" i][contenteditable="true"]').first
|
||||
await body.click()
|
||||
await page.keyboard.press("Control+Home")
|
||||
await page.keyboard.type(text, delay=10)
|
||||
return {"status": "written", "chars": len(text)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def set_recipients(to: list[str], cc: Optional[list[str]] = None) -> dict:
|
||||
"""Vyplní To / Cc v otevřeném draftu. Adresy oddělené středníkem, OWA si je sám zvalidčuje."""
|
||||
await _ensure_started()
|
||||
if State.draft_page is None or State.draft_page.is_closed():
|
||||
return {"status": "no_draft"}
|
||||
page = State.draft_page
|
||||
|
||||
to_field = page.locator('div[aria-label*="To" i][contenteditable="true"], '
|
||||
'div[aria-label*="Komu" i][contenteditable="true"]').first
|
||||
await to_field.click()
|
||||
await page.keyboard.type("; ".join(to) + ";", delay=15)
|
||||
await page.wait_for_timeout(500)
|
||||
|
||||
if cc:
|
||||
cc_field = page.locator('div[aria-label*="Cc" i][contenteditable="true"], '
|
||||
'div[aria-label*="Kopie" i][contenteditable="true"]').first
|
||||
if await cc_field.count():
|
||||
await cc_field.click()
|
||||
await page.keyboard.type("; ".join(cc) + ";", delay=15)
|
||||
|
||||
return {"status": "filled", "to": to, "cc": cc or []}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def close_reading_pane() -> dict:
|
||||
"""Zavře otevřený email v reading pane (Escape). Forward draft tím nezavře."""
|
||||
await _ensure_started()
|
||||
await State.main_page.keyboard.press("Escape")
|
||||
await State.main_page.wait_for_timeout(300)
|
||||
return {"status": "closed"}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def screenshot(path: str = "owa_screenshot.png") -> dict:
|
||||
"""Uloží screenshot aktivního okna pro orientaci."""
|
||||
await _ensure_started()
|
||||
out = (BASE_DIR / path).resolve()
|
||||
await State.main_page.screenshot(path=str(out), full_page=False)
|
||||
return {"status": "ok", "path": str(out)}
|
||||
|
||||
|
||||
# ── Entry ──────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
log("mcp_owa v1.0 starting (stdio)…")
|
||||
mcp.run()
|
||||
@@ -0,0 +1,68 @@
|
||||
# mcp_owa_v1.1
|
||||
|
||||
MCP server pro OWA (outlook.cloud.microsoft) — drží persistentní Playwright session a umožňuje claude-cowork:
|
||||
- vyhledat email v MongoDB `OperativniEmailyJNJ.messages`,
|
||||
- otevřít ho v OWA UI,
|
||||
- udělat Forward, vepsat úvodní text na začátek těla,
|
||||
- zavřít původní čtecí panel.
|
||||
|
||||
Odeslání forwardu **dělá uživatel ručně** v okně, které zůstává otevřené.
|
||||
|
||||
## Změny v1.1
|
||||
- Sync Playwright v dedikovaném worker threadu místo `async_playwright`.
|
||||
Důvod: na Windows s persistent contextem `async_playwright` selhává — Chrome se zavře hned po startu (problém s asyncio ProactorEventLoop + `--remote-debugging-pipe`). Sync API funguje spolehlivě (potvrzeno v `import_emails_to_mongo_v1.0.py`).
|
||||
- FastMCP tooly jsou synchronní, dispatch na worker přes `queue.Queue` + `concurrent.futures.Future`.
|
||||
|
||||
## Spuštění
|
||||
Registrace v `.mcp.json`:
|
||||
```json
|
||||
"owa": {
|
||||
"command": "python",
|
||||
"args": ["U:\\PythonProject\\Janssen\\Outlook\\mcp_owa_v1.1.py"],
|
||||
"cwd": "U:\\PythonProject\\Janssen\\Outlook"
|
||||
}
|
||||
```
|
||||
|
||||
Profil sdílí s `import_emails_to_mongo_v1.0.py` (`outlook_profile/`). Pokud profil neexistuje, spusť nejdřív `outlook_login_v1.0.py`.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Účel |
|
||||
|------|------|
|
||||
| `start_owa` | Spustí Playwright + otevře OWA |
|
||||
| `stop_owa` | Zavře okno |
|
||||
| `status` | Stav session |
|
||||
| `find_emails(query, from_email, folder, since_iso, limit)` | Hledání v MongoDB |
|
||||
| `find_last_email(from_email, folder)` | Nejnovější email |
|
||||
| `open_email_by_subject(subject)` | Otevře v OWA přes search |
|
||||
| `forward_current(body_prefix, subject_prefix)` | Ctrl+Shift+F, předvyplní |
|
||||
| `write_at_top(text)` | Vepíše text na začátek body draftu |
|
||||
| `set_recipients(to, cc)` | Doplní To/Cc |
|
||||
| `close_reading_pane` | Escape (zavře čtecí panel) |
|
||||
| `screenshot(path)` | Diagnostický screenshot |
|
||||
|
||||
## Typický flow
|
||||
|
||||
```
|
||||
start_owa
|
||||
find_last_email(from_email="...") → vrátí subject + metadata
|
||||
open_email_by_subject("<subject>") → otevře v reading pane
|
||||
forward_current(body_prefix="Posílám dále, prosím o vyjádření.\n")
|
||||
→ Ctrl+Shift+F, vepíše úvod
|
||||
set_recipients(to=["adresat@..."]) → vyplní příjemce
|
||||
→ uživatel zkontroluje a odešle ručně
|
||||
close_reading_pane (až po odeslání)
|
||||
```
|
||||
|
||||
## Poznámky / známá omezení
|
||||
- Forward draft = stejný tab jako reading pane (inline composer).
|
||||
- Podpis se vkládá automaticky podle nastavení OWA.
|
||||
- OWA si sám předřadí `FW: ` k předmětu; `subject_prefix` se přidá před to.
|
||||
- Hledání emailu v UI přes search bar (subject substring).
|
||||
- Pokud OWA změní lokalizaci aria-labelů, uprav selektory v `_forward`, `_write_at_top`, `_set_recipients`.
|
||||
|
||||
## TODO pro další verzi
|
||||
- Otevření přímo z `message_id`
|
||||
- Detekce, zda forward composer otevřel popup tab místo inline
|
||||
- `send_forward()` tool s explicitním potvrzením
|
||||
- Volba složky před `open_email_by_subject`
|
||||
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
=======================================================================
|
||||
Název: mcp_owa_v1.1.py
|
||||
Verze: 1.1
|
||||
Datum: 2026-06-04
|
||||
Popis: MCP server pro práci s otevřeným OWA oknem (Playwright).
|
||||
Drží persistent session a vystavuje tooly:
|
||||
- vyhledání emailu v MongoDB OperativniEmailyJNJ.messages
|
||||
- otevření emailu v OWA UI přes search
|
||||
- Forward → vepsání úvodního textu na začátek body
|
||||
- zavření původního čtecího panelu
|
||||
Odeslání forwardu dělá uživatel sám.
|
||||
Profil sdílí s import_emails_to_mongo_v1.0.py (outlook_profile/).
|
||||
|
||||
Změny v1.1:
|
||||
- sync_playwright v dedikovaném worker threadu (Windows + async_playwright
|
||||
v persistent contextu padá: Chrome se zavře hned po startu).
|
||||
- FastMCP tooly synchronní, dispatch na worker přes queue + Future.
|
||||
|
||||
Spuštění: python mcp_owa_v1.1.py
|
||||
=======================================================================
|
||||
"""
|
||||
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
from concurrent.futures import Future
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from playwright.sync_api import sync_playwright, Page, BrowserContext
|
||||
from pymongo import MongoClient, DESCENDING
|
||||
|
||||
# ── Konfigurace ────────────────────────────────────────────────────────
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
PROFILE_DIR = BASE_DIR / "outlook_profile"
|
||||
START_URL = "https://outlook.cloud.microsoft/mail/"
|
||||
|
||||
MONGO_URI = "mongodb://192.168.1.76:27017"
|
||||
DB_NAME = "OperativniEmailyJNJ"
|
||||
COL_NAME = "messages"
|
||||
|
||||
SEARCH_READY = (
|
||||
'[placeholder*="Search"], [aria-label*="Search"], '
|
||||
'[placeholder*="Hledat"], [aria-label*="Hledat"]'
|
||||
)
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
# ── Worker thread (drží Playwright) ────────────────────────────────────
|
||||
|
||||
class PWWorker:
|
||||
def __init__(self):
|
||||
self.q: queue.Queue = queue.Queue()
|
||||
self.ready = threading.Event()
|
||||
self.pw = None
|
||||
self.context: Optional[BrowserContext] = None
|
||||
self.main_page: Optional[Page] = None
|
||||
self.draft_page: Optional[Page] = None
|
||||
self.thread = threading.Thread(target=self._run, daemon=True, name="pw-worker")
|
||||
self.thread.start()
|
||||
self.ready.wait(timeout=10)
|
||||
|
||||
def _run(self):
|
||||
with sync_playwright() as p:
|
||||
self.pw = p
|
||||
self.ready.set()
|
||||
log("pw-worker: sync_playwright ready")
|
||||
while True:
|
||||
item = self.q.get()
|
||||
if item is None:
|
||||
break
|
||||
fn, args, kwargs, fut = item
|
||||
try:
|
||||
fut.set_result(fn(self, *args, **kwargs))
|
||||
except Exception as e:
|
||||
fut.set_exception(e)
|
||||
|
||||
def call(self, fn, *args, **kwargs):
|
||||
fut: Future = Future()
|
||||
self.q.put((fn, args, kwargs, fut))
|
||||
return fut.result()
|
||||
|
||||
|
||||
WORKER = PWWorker()
|
||||
_mongo = MongoClient(MONGO_URI)
|
||||
_col = _mongo[DB_NAME][COL_NAME]
|
||||
mcp = FastMCP("owa")
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _doc_summary(doc: dict) -> dict:
|
||||
return {
|
||||
"message_id": doc.get("message_id"),
|
||||
"subject": doc.get("subject"),
|
||||
"from": doc.get("from"),
|
||||
"to": doc.get("to", [])[:5],
|
||||
"date": doc.get("date").isoformat() if doc.get("date") else None,
|
||||
"folder": doc.get("folder"),
|
||||
"has_attachments": doc.get("has_attachments", False),
|
||||
}
|
||||
|
||||
|
||||
def _wait_ready(page: Page):
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
page.wait_for_selector(SEARCH_READY, timeout=30_000)
|
||||
|
||||
|
||||
# ── Worker functions (běží na pw threadu, dostávají worker jako 1. arg) ─
|
||||
|
||||
def _start(w: PWWorker) -> dict:
|
||||
if w.context is not None:
|
||||
return {"status": "already_running", "url": w.main_page.url if w.main_page else None}
|
||||
if not PROFILE_DIR.exists():
|
||||
return {"status": "error", "error": f"Profil nenalezen: {PROFILE_DIR}. Spusť outlook_login_v1.0.py."}
|
||||
w.context = w.pw.chromium.launch_persistent_context(
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
headless=False,
|
||||
no_viewport=True,
|
||||
accept_downloads=True,
|
||||
args=[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--start-maximized",
|
||||
],
|
||||
)
|
||||
w.main_page = w.context.pages[0] if w.context.pages else w.context.new_page()
|
||||
w.main_page.goto(START_URL)
|
||||
_wait_ready(w.main_page)
|
||||
return {"status": "started", "url": w.main_page.url}
|
||||
|
||||
|
||||
def _stop(w: PWWorker) -> dict:
|
||||
if w.context is None:
|
||||
return {"status": "not_running"}
|
||||
try:
|
||||
w.context.close()
|
||||
finally:
|
||||
w.context = None
|
||||
w.main_page = None
|
||||
w.draft_page = None
|
||||
return {"status": "stopped"}
|
||||
|
||||
|
||||
def _status(w: PWWorker) -> dict:
|
||||
if w.context is None:
|
||||
return {"running": False}
|
||||
return {
|
||||
"running": True,
|
||||
"url": w.main_page.url if w.main_page else None,
|
||||
"tabs": len(w.context.pages),
|
||||
"draft_open": w.draft_page is not None and not w.draft_page.is_closed(),
|
||||
}
|
||||
|
||||
|
||||
def _open_by_subject(w: PWWorker, subject: str) -> dict:
|
||||
if w.context is None:
|
||||
return {"status": "not_running"}
|
||||
page = w.main_page
|
||||
search = page.locator(SEARCH_READY).first
|
||||
search.click()
|
||||
search.fill("")
|
||||
search.type(subject, delay=20)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(2_000)
|
||||
msgs = page.locator('div[role="option"]')
|
||||
try:
|
||||
msgs.first.wait_for(state="visible", timeout=10_000)
|
||||
except Exception:
|
||||
return {"status": "no_results"}
|
||||
msgs.first.click()
|
||||
page.wait_for_timeout(800)
|
||||
return {"status": "opened", "count_visible": msgs.count()}
|
||||
|
||||
|
||||
def _forward(w: PWWorker, body_prefix: str = "", subject_prefix: str = "") -> dict:
|
||||
if w.context is None:
|
||||
return {"status": "not_running"}
|
||||
page = w.main_page
|
||||
page.keyboard.press("Control+Shift+F")
|
||||
page.wait_for_timeout(1_500)
|
||||
|
||||
body = page.locator(
|
||||
'div[aria-label*="Message body" i][contenteditable="true"], '
|
||||
'div[aria-label*="Tělo zprávy" i][contenteditable="true"]'
|
||||
).first
|
||||
try:
|
||||
body.wait_for(state="visible", timeout=10_000)
|
||||
except Exception:
|
||||
return {"status": "forward_failed", "hint": "Body editor forwardu nenalezen."}
|
||||
|
||||
w.draft_page = page # inline composer ve stejném tabu
|
||||
|
||||
if subject_prefix:
|
||||
subj = page.locator(
|
||||
'input[aria-label*="subject" i], input[aria-label*="předmět" i]'
|
||||
).first
|
||||
try:
|
||||
subj.wait_for(state="visible", timeout=5_000)
|
||||
current = subj.input_value()
|
||||
subj.fill(f"{subject_prefix}{current}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if body_prefix:
|
||||
body.click()
|
||||
page.keyboard.press("Control+Home")
|
||||
page.keyboard.type(body_prefix, delay=10)
|
||||
page.keyboard.press("Enter")
|
||||
|
||||
return {"status": "forward_ready"}
|
||||
|
||||
|
||||
def _write_at_top(w: PWWorker, text: str) -> dict:
|
||||
if w.draft_page is None or w.draft_page.is_closed():
|
||||
return {"status": "no_draft"}
|
||||
page = w.draft_page
|
||||
body = page.locator(
|
||||
'div[aria-label*="Message body" i][contenteditable="true"], '
|
||||
'div[aria-label*="Tělo zprávy" i][contenteditable="true"]'
|
||||
).first
|
||||
body.click()
|
||||
page.keyboard.press("Control+Home")
|
||||
page.keyboard.type(text, delay=10)
|
||||
return {"status": "written", "chars": len(text)}
|
||||
|
||||
|
||||
def _set_recipients(w: PWWorker, to: list[str], cc: Optional[list[str]] = None) -> dict:
|
||||
if w.draft_page is None or w.draft_page.is_closed():
|
||||
return {"status": "no_draft"}
|
||||
page = w.draft_page
|
||||
to_field = page.locator(
|
||||
'div[aria-label*="To" i][contenteditable="true"], '
|
||||
'div[aria-label*="Komu" i][contenteditable="true"]'
|
||||
).first
|
||||
to_field.click()
|
||||
page.keyboard.type("; ".join(to) + ";", delay=15)
|
||||
page.wait_for_timeout(500)
|
||||
if cc:
|
||||
cc_field = page.locator(
|
||||
'div[aria-label*="Cc" i][contenteditable="true"], '
|
||||
'div[aria-label*="Kopie" i][contenteditable="true"]'
|
||||
).first
|
||||
if cc_field.count():
|
||||
cc_field.click()
|
||||
page.keyboard.type("; ".join(cc) + ";", delay=15)
|
||||
return {"status": "filled", "to": to, "cc": cc or []}
|
||||
|
||||
|
||||
def _close_pane(w: PWWorker) -> dict:
|
||||
if w.context is None:
|
||||
return {"status": "not_running"}
|
||||
w.main_page.keyboard.press("Escape")
|
||||
w.main_page.wait_for_timeout(300)
|
||||
return {"status": "closed"}
|
||||
|
||||
|
||||
def _screenshot(w: PWWorker, path: str) -> dict:
|
||||
if w.context is None:
|
||||
return {"status": "not_running"}
|
||||
out = (BASE_DIR / path).resolve()
|
||||
w.main_page.screenshot(path=str(out), full_page=False)
|
||||
return {"status": "ok", "path": str(out)}
|
||||
|
||||
|
||||
# ── MCP tooly (synchronní; dispatch na worker) ─────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
def start_owa() -> dict:
|
||||
"""Spustí Playwright s persistent profilem a otevře OWA. Okno zůstane otevřené."""
|
||||
return WORKER.call(_start)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def stop_owa() -> dict:
|
||||
"""Zavře Playwright context (a tím i okno OWA)."""
|
||||
return WORKER.call(_stop)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def status() -> dict:
|
||||
"""Vrátí stav session: běží/neběží, URL, počet tabů, draft otevřen?"""
|
||||
return WORKER.call(_status)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def find_emails(
|
||||
query: Optional[str] = None,
|
||||
from_email: Optional[str] = None,
|
||||
folder: Optional[str] = None,
|
||||
since_iso: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""Hledá v MongoDB. `query` = substring v subjectu (case-insensitive).
|
||||
`since_iso` = ISO datum, vrátí jen emaily od něj. Seřazeno od nejnovějšího."""
|
||||
flt: dict = {}
|
||||
if query:
|
||||
flt["subject"] = {"$regex": query, "$options": "i"}
|
||||
if from_email:
|
||||
flt["from.email"] = {"$regex": from_email, "$options": "i"}
|
||||
if folder:
|
||||
flt["folder"] = folder
|
||||
if since_iso:
|
||||
try:
|
||||
dt = datetime.fromisoformat(since_iso.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
flt["date"] = {"$gte": dt}
|
||||
except Exception:
|
||||
pass
|
||||
cur = _col.find(flt).sort("date", DESCENDING).limit(max(1, min(limit, 50)))
|
||||
return [_doc_summary(d) for d in cur]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def find_last_email(from_email: Optional[str] = None, folder: Optional[str] = None) -> Optional[dict]:
|
||||
"""Vrátí nejnovější email (volitelně filtr podle odesílatele / složky)."""
|
||||
flt: dict = {}
|
||||
if from_email:
|
||||
flt["from.email"] = {"$regex": from_email, "$options": "i"}
|
||||
if folder:
|
||||
flt["folder"] = folder
|
||||
docs = list(_col.find(flt).sort("date", DESCENDING).limit(1))
|
||||
return _doc_summary(docs[0]) if docs else None
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def open_email_by_subject(subject: str) -> dict:
|
||||
"""Vyhledá v OWA podle subjectu a otevře první výsledek v reading pane."""
|
||||
return WORKER.call(_open_by_subject, subject)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def forward_current(body_prefix: str = "", subject_prefix: str = "") -> dict:
|
||||
"""Klikne Forward (Ctrl+Shift+F). Pokud je `body_prefix`, vepíše ho na začátek body.
|
||||
Pokud `subject_prefix`, předřadí ho do předmětu draftu."""
|
||||
return WORKER.call(_forward, body_prefix, subject_prefix)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def write_at_top(text: str) -> dict:
|
||||
"""Vepíše text na začátek body otevřeného draftu (před existující obsah / podpis)."""
|
||||
return WORKER.call(_write_at_top, text)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_recipients(to: list[str], cc: Optional[list[str]] = None) -> dict:
|
||||
"""Vyplní To / Cc v otevřeném draftu."""
|
||||
return WORKER.call(_set_recipients, to, cc)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def close_reading_pane() -> dict:
|
||||
"""Zavře otevřený email v reading pane (Escape). Forward draft tím nezavře."""
|
||||
return WORKER.call(_close_pane)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def screenshot(path: str = "owa_screenshot.png") -> dict:
|
||||
"""Uloží screenshot aktivního okna pro orientaci."""
|
||||
return WORKER.call(_screenshot, path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
log("mcp_owa v1.1 starting (stdio)…")
|
||||
mcp.run()
|
||||
@@ -0,0 +1,68 @@
|
||||
# mcp_owa_v1.2
|
||||
|
||||
MCP server pro OWA (outlook.cloud.microsoft) — drží persistentní Playwright session ve **vlastním profilu** `owa_mcp_profile/`.
|
||||
|
||||
## Změny v1.2
|
||||
- **Vlastní profil** `owa_mcp_profile/` — nesdílí se s `import_emails_to_mongo_v1.0.py`. Lze tak používat oba současně bez konfliktu zámku Chrome.
|
||||
- **Nový tool `login()`** — pro první přihlášení / přepnutí účtu. Otevře okno, počká až 5 minut, než dokončíš login ručně. Profil se uloží automaticky.
|
||||
- `start_owa` vrátí `no_profile`, pokud `owa_mcp_profile/` neexistuje — pak zavolej `login`.
|
||||
- `status` vrací i `profile_exists` + `profile_dir`.
|
||||
|
||||
## Změny v1.1
|
||||
- Sync Playwright v dedikovaném worker threadu místo `async_playwright` (na Windows + persistent context async API padá hned po startu Chrome).
|
||||
|
||||
## První použití
|
||||
1. Zavolej `login` → otevře se Chrome s OWA.
|
||||
2. Přihlas se ručně (Microsoft SSO atd.).
|
||||
3. Po načtení inboxu se tool vrátí `logged_in` (nebo `window_open_login_pending` při timeoutu — to neva, profil se i tak ukládá průběžně).
|
||||
4. Příště stačí `start_owa`.
|
||||
|
||||
## Spuštění
|
||||
Registrace v `.mcp.json`:
|
||||
```json
|
||||
"owa": {
|
||||
"command": "python",
|
||||
"args": ["U:\\PythonProject\\Janssen\\Outlook\\mcp_owa_v1.2.py"],
|
||||
"cwd": "U:\\PythonProject\\Janssen\\Outlook"
|
||||
}
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Účel |
|
||||
|------|------|
|
||||
| `login` | První přihlášení; otevře OWA na 5 min, profil se uloží |
|
||||
| `start_owa` | Spustí Playwright + otevře OWA (vyžaduje existující profil) |
|
||||
| `stop_owa` | Zavře okno (profil zůstává uložený) |
|
||||
| `status` | Stav session + existuje profil? |
|
||||
| `find_emails(query, from_email, folder, since_iso, limit)` | Hledání v MongoDB |
|
||||
| `find_last_email(from_email, folder)` | Nejnovější email |
|
||||
| `open_email_by_subject(subject)` | Otevře v OWA přes search |
|
||||
| `forward_current(body_prefix, subject_prefix)` | Ctrl+Shift+F, předvyplní |
|
||||
| `write_at_top(text)` | Vepíše text na začátek body draftu |
|
||||
| `set_recipients(to, cc)` | Doplní To/Cc |
|
||||
| `close_reading_pane` | Escape |
|
||||
| `screenshot(path)` | Diagnostický screenshot |
|
||||
|
||||
## Typický flow (po prvním loginu)
|
||||
```
|
||||
start_owa
|
||||
find_last_email(from_email="...")
|
||||
open_email_by_subject("<subject>")
|
||||
forward_current(body_prefix="Posílám dále, prosím o vyjádření.\n")
|
||||
set_recipients(to=["adresat@..."])
|
||||
# uživatel zkontroluje a odešle ručně
|
||||
```
|
||||
|
||||
## Známá omezení
|
||||
- Forward draft je inline composer ve stejném tabu.
|
||||
- Podpis vkládá OWA automaticky podle nastavení účtu.
|
||||
- OWA si sám předřadí `FW:` k předmětu; `subject_prefix` se přidá před to.
|
||||
- Pokud OWA změní lokalizaci aria-labelů, uprav selektory v `_forward`, `_write_at_top`, `_set_recipients`.
|
||||
|
||||
## TODO
|
||||
- Otevření přímo z `message_id`
|
||||
- Detekce popup composer vs. inline
|
||||
- `send_forward()` s explicitním potvrzením
|
||||
- Volba složky před `open_email_by_subject`
|
||||
- `logout()` (smaže profil)
|
||||
@@ -0,0 +1,426 @@
|
||||
"""
|
||||
=======================================================================
|
||||
Název: mcp_owa_v1.2.py
|
||||
Verze: 1.2
|
||||
Datum: 2026-06-04
|
||||
Popis: MCP server pro práci s otevřeným OWA oknem (Playwright).
|
||||
Drží persistent session s vlastním profilem `owa_mcp_profile/`
|
||||
a vystavuje tooly:
|
||||
- login (první přihlášení; uživatel se přihlásí ručně,
|
||||
profil se uloží do owa_mcp_profile/)
|
||||
- vyhledání emailu v MongoDB OperativniEmailyJNJ.messages
|
||||
- otevření emailu v OWA UI přes search
|
||||
- Forward → vepsání úvodního textu na začátek body
|
||||
- zavření původního čtecího panelu
|
||||
Odeslání forwardu dělá uživatel sám.
|
||||
|
||||
Změny v1.2:
|
||||
- Vlastní profil `owa_mcp_profile/` (nesdílí se s import_emails_to_mongo).
|
||||
- Nový tool `login()` — otevře OWA pro ruční přihlášení;
|
||||
po úspěšném loginu zůstane profil v owa_mcp_profile/ pro další běhy.
|
||||
- `start_owa` automaticky upozorní, když profil neexistuje (-> volej login).
|
||||
|
||||
Změny v1.1:
|
||||
- sync_playwright v dedikovaném worker threadu (Windows + async_playwright
|
||||
v persistent contextu padá: Chrome se zavře hned po startu).
|
||||
- FastMCP tooly synchronní, dispatch na worker přes queue + Future.
|
||||
|
||||
Spuštění: python mcp_owa_v1.2.py
|
||||
=======================================================================
|
||||
"""
|
||||
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
from concurrent.futures import Future
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from playwright.sync_api import sync_playwright, Page, BrowserContext
|
||||
from pymongo import MongoClient, DESCENDING
|
||||
|
||||
# ── Konfigurace ────────────────────────────────────────────────────────
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
PROFILE_DIR = BASE_DIR / "owa_mcp_profile" # ← vlastní profil pro tento MCP server
|
||||
START_URL = "https://outlook.cloud.microsoft/mail/"
|
||||
|
||||
MONGO_URI = "mongodb://192.168.1.76:27017"
|
||||
DB_NAME = "OperativniEmailyJNJ"
|
||||
COL_NAME = "messages"
|
||||
|
||||
SEARCH_READY = (
|
||||
'[placeholder*="Search"], [aria-label*="Search"], '
|
||||
'[placeholder*="Hledat"], [aria-label*="Hledat"]'
|
||||
)
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
# ── Worker thread (drží Playwright) ────────────────────────────────────
|
||||
|
||||
class PWWorker:
|
||||
def __init__(self):
|
||||
self.q: queue.Queue = queue.Queue()
|
||||
self.ready = threading.Event()
|
||||
self.pw = None
|
||||
self.context: Optional[BrowserContext] = None
|
||||
self.main_page: Optional[Page] = None
|
||||
self.draft_page: Optional[Page] = None
|
||||
self.thread = threading.Thread(target=self._run, daemon=True, name="pw-worker")
|
||||
self.thread.start()
|
||||
self.ready.wait(timeout=10)
|
||||
|
||||
def _run(self):
|
||||
with sync_playwright() as p:
|
||||
self.pw = p
|
||||
self.ready.set()
|
||||
log("pw-worker: sync_playwright ready")
|
||||
while True:
|
||||
item = self.q.get()
|
||||
if item is None:
|
||||
break
|
||||
fn, args, kwargs, fut = item
|
||||
try:
|
||||
fut.set_result(fn(self, *args, **kwargs))
|
||||
except Exception as e:
|
||||
fut.set_exception(e)
|
||||
|
||||
def call(self, fn, *args, **kwargs):
|
||||
fut: Future = Future()
|
||||
self.q.put((fn, args, kwargs, fut))
|
||||
return fut.result()
|
||||
|
||||
|
||||
WORKER = PWWorker()
|
||||
_mongo = MongoClient(MONGO_URI)
|
||||
_col = _mongo[DB_NAME][COL_NAME]
|
||||
mcp = FastMCP("owa")
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _doc_summary(doc: dict) -> dict:
|
||||
return {
|
||||
"message_id": doc.get("message_id"),
|
||||
"subject": doc.get("subject"),
|
||||
"from": doc.get("from"),
|
||||
"to": doc.get("to", [])[:5],
|
||||
"date": doc.get("date").isoformat() if doc.get("date") else None,
|
||||
"folder": doc.get("folder"),
|
||||
"has_attachments": doc.get("has_attachments", False),
|
||||
}
|
||||
|
||||
|
||||
def _wait_ready(page: Page, timeout: int = 30_000):
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
page.wait_for_selector(SEARCH_READY, timeout=timeout)
|
||||
|
||||
|
||||
def _launch_context(w: PWWorker) -> BrowserContext:
|
||||
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return w.pw.chromium.launch_persistent_context(
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
headless=False,
|
||||
no_viewport=True,
|
||||
accept_downloads=True,
|
||||
args=[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--start-maximized",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ── Worker functions ───────────────────────────────────────────────────
|
||||
|
||||
def _login(w: PWWorker) -> dict:
|
||||
"""Otevře OWA pro ruční přihlášení. Profil se uloží do owa_mcp_profile/.
|
||||
Po přihlášení uživatel volá zavřít okno (nebo nechá běžet a pokračuje)."""
|
||||
if w.context is not None:
|
||||
return {
|
||||
"status": "already_running",
|
||||
"url": w.main_page.url if w.main_page else None,
|
||||
"hint": "Pokud se chceš přihlásit znovu/jiným účtem, nejprve stop_owa a smaž owa_mcp_profile/."
|
||||
}
|
||||
w.context = _launch_context(w)
|
||||
w.main_page = w.context.pages[0] if w.context.pages else w.context.new_page()
|
||||
w.main_page.goto(START_URL)
|
||||
# Po loginu se objeví searchbar; čekej dlouho, uživatel může klikat
|
||||
try:
|
||||
_wait_ready(w.main_page, timeout=300_000) # až 5 minut na login
|
||||
status_msg = "logged_in"
|
||||
except Exception:
|
||||
status_msg = "window_open_login_pending"
|
||||
return {
|
||||
"status": status_msg,
|
||||
"profile_dir": str(PROFILE_DIR),
|
||||
"url": w.main_page.url,
|
||||
"hint": "Profil je uložen. Příště volej start_owa a budeš rovnou přihlášen.",
|
||||
}
|
||||
|
||||
|
||||
def _start(w: PWWorker) -> dict:
|
||||
if w.context is not None:
|
||||
return {"status": "already_running", "url": w.main_page.url if w.main_page else None}
|
||||
if not PROFILE_DIR.exists() or not any(PROFILE_DIR.iterdir()):
|
||||
return {
|
||||
"status": "no_profile",
|
||||
"error": f"Profil {PROFILE_DIR} neexistuje nebo je prázdný.",
|
||||
"hint": "Zavolej nejprve `login` a přihlas se ručně v otevřeném okně.",
|
||||
}
|
||||
w.context = _launch_context(w)
|
||||
w.main_page = w.context.pages[0] if w.context.pages else w.context.new_page()
|
||||
w.main_page.goto(START_URL)
|
||||
_wait_ready(w.main_page)
|
||||
return {"status": "started", "url": w.main_page.url}
|
||||
|
||||
|
||||
def _stop(w: PWWorker) -> dict:
|
||||
if w.context is None:
|
||||
return {"status": "not_running"}
|
||||
try:
|
||||
w.context.close()
|
||||
finally:
|
||||
w.context = None
|
||||
w.main_page = None
|
||||
w.draft_page = None
|
||||
return {"status": "stopped"}
|
||||
|
||||
|
||||
def _status(w: PWWorker) -> dict:
|
||||
profile_exists = PROFILE_DIR.exists() and any(PROFILE_DIR.iterdir())
|
||||
if w.context is None:
|
||||
return {"running": False, "profile_exists": profile_exists, "profile_dir": str(PROFILE_DIR)}
|
||||
return {
|
||||
"running": True,
|
||||
"url": w.main_page.url if w.main_page else None,
|
||||
"tabs": len(w.context.pages),
|
||||
"draft_open": w.draft_page is not None and not w.draft_page.is_closed(),
|
||||
"profile_exists": profile_exists,
|
||||
"profile_dir": str(PROFILE_DIR),
|
||||
}
|
||||
|
||||
|
||||
def _open_by_subject(w: PWWorker, subject: str) -> dict:
|
||||
if w.context is None:
|
||||
return {"status": "not_running"}
|
||||
page = w.main_page
|
||||
search = page.locator(SEARCH_READY).first
|
||||
search.click()
|
||||
search.fill("")
|
||||
search.type(subject, delay=20)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(2_000)
|
||||
msgs = page.locator('div[role="option"]')
|
||||
try:
|
||||
msgs.first.wait_for(state="visible", timeout=10_000)
|
||||
except Exception:
|
||||
return {"status": "no_results"}
|
||||
msgs.first.click()
|
||||
page.wait_for_timeout(800)
|
||||
return {"status": "opened", "count_visible": msgs.count()}
|
||||
|
||||
|
||||
def _forward(w: PWWorker, body_prefix: str = "", subject_prefix: str = "") -> dict:
|
||||
if w.context is None:
|
||||
return {"status": "not_running"}
|
||||
page = w.main_page
|
||||
page.keyboard.press("Control+Shift+F")
|
||||
page.wait_for_timeout(1_500)
|
||||
|
||||
body = page.locator(
|
||||
'div[aria-label*="Message body" i][contenteditable="true"], '
|
||||
'div[aria-label*="Tělo zprávy" i][contenteditable="true"]'
|
||||
).first
|
||||
try:
|
||||
body.wait_for(state="visible", timeout=10_000)
|
||||
except Exception:
|
||||
return {"status": "forward_failed", "hint": "Body editor forwardu nenalezen."}
|
||||
|
||||
w.draft_page = page
|
||||
|
||||
if subject_prefix:
|
||||
subj = page.locator(
|
||||
'input[aria-label*="subject" i], input[aria-label*="předmět" i]'
|
||||
).first
|
||||
try:
|
||||
subj.wait_for(state="visible", timeout=5_000)
|
||||
current = subj.input_value()
|
||||
subj.fill(f"{subject_prefix}{current}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if body_prefix:
|
||||
body.click()
|
||||
page.keyboard.press("Control+Home")
|
||||
page.keyboard.type(body_prefix, delay=10)
|
||||
page.keyboard.press("Enter")
|
||||
|
||||
return {"status": "forward_ready"}
|
||||
|
||||
|
||||
def _write_at_top(w: PWWorker, text: str) -> dict:
|
||||
if w.draft_page is None or w.draft_page.is_closed():
|
||||
return {"status": "no_draft"}
|
||||
page = w.draft_page
|
||||
body = page.locator(
|
||||
'div[aria-label*="Message body" i][contenteditable="true"], '
|
||||
'div[aria-label*="Tělo zprávy" i][contenteditable="true"]'
|
||||
).first
|
||||
body.click()
|
||||
page.keyboard.press("Control+Home")
|
||||
page.keyboard.type(text, delay=10)
|
||||
return {"status": "written", "chars": len(text)}
|
||||
|
||||
|
||||
def _set_recipients(w: PWWorker, to: list[str], cc: Optional[list[str]] = None) -> dict:
|
||||
if w.draft_page is None or w.draft_page.is_closed():
|
||||
return {"status": "no_draft"}
|
||||
page = w.draft_page
|
||||
to_field = page.locator(
|
||||
'div[aria-label*="To" i][contenteditable="true"], '
|
||||
'div[aria-label*="Komu" i][contenteditable="true"]'
|
||||
).first
|
||||
to_field.click()
|
||||
page.keyboard.type("; ".join(to) + ";", delay=15)
|
||||
page.wait_for_timeout(500)
|
||||
if cc:
|
||||
cc_field = page.locator(
|
||||
'div[aria-label*="Cc" i][contenteditable="true"], '
|
||||
'div[aria-label*="Kopie" i][contenteditable="true"]'
|
||||
).first
|
||||
if cc_field.count():
|
||||
cc_field.click()
|
||||
page.keyboard.type("; ".join(cc) + ";", delay=15)
|
||||
return {"status": "filled", "to": to, "cc": cc or []}
|
||||
|
||||
|
||||
def _close_pane(w: PWWorker) -> dict:
|
||||
if w.context is None:
|
||||
return {"status": "not_running"}
|
||||
w.main_page.keyboard.press("Escape")
|
||||
w.main_page.wait_for_timeout(300)
|
||||
return {"status": "closed"}
|
||||
|
||||
|
||||
def _screenshot(w: PWWorker, path: str) -> dict:
|
||||
if w.context is None:
|
||||
return {"status": "not_running"}
|
||||
out = (BASE_DIR / path).resolve()
|
||||
w.main_page.screenshot(path=str(out), full_page=False)
|
||||
return {"status": "ok", "path": str(out)}
|
||||
|
||||
|
||||
# ── MCP tooly ──────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
def login() -> dict:
|
||||
"""První přihlášení (nebo přihlášení jiným účtem). Otevře OWA okno
|
||||
a počká až 5 minut, než dokončíš login ručně. Profil se uloží do
|
||||
owa_mcp_profile/ a příště stačí volat start_owa."""
|
||||
return WORKER.call(_login)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def start_owa() -> dict:
|
||||
"""Spustí Playwright s persistent profilem a otevře OWA. Pokud profil neexistuje,
|
||||
vrátí no_profile — pak zavolej login."""
|
||||
return WORKER.call(_start)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def stop_owa() -> dict:
|
||||
"""Zavře Playwright context (a tím i okno OWA). Profil zůstane uložený."""
|
||||
return WORKER.call(_stop)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def status() -> dict:
|
||||
"""Vrátí stav session: běží/neběží, URL, počet tabů, draft otevřen, existuje profil?"""
|
||||
return WORKER.call(_status)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def find_emails(
|
||||
query: Optional[str] = None,
|
||||
from_email: Optional[str] = None,
|
||||
folder: Optional[str] = None,
|
||||
since_iso: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""Hledá v MongoDB. `query` = substring v subjectu (case-insensitive).
|
||||
`since_iso` = ISO datum, vrátí jen emaily od něj. Seřazeno od nejnovějšího."""
|
||||
flt: dict = {}
|
||||
if query:
|
||||
flt["subject"] = {"$regex": query, "$options": "i"}
|
||||
if from_email:
|
||||
flt["from.email"] = {"$regex": from_email, "$options": "i"}
|
||||
if folder:
|
||||
flt["folder"] = folder
|
||||
if since_iso:
|
||||
try:
|
||||
dt = datetime.fromisoformat(since_iso.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
flt["date"] = {"$gte": dt}
|
||||
except Exception:
|
||||
pass
|
||||
cur = _col.find(flt).sort("date", DESCENDING).limit(max(1, min(limit, 50)))
|
||||
return [_doc_summary(d) for d in cur]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def find_last_email(from_email: Optional[str] = None, folder: Optional[str] = None) -> Optional[dict]:
|
||||
"""Vrátí nejnovější email (volitelně filtr podle odesílatele / složky)."""
|
||||
flt: dict = {}
|
||||
if from_email:
|
||||
flt["from.email"] = {"$regex": from_email, "$options": "i"}
|
||||
if folder:
|
||||
flt["folder"] = folder
|
||||
docs = list(_col.find(flt).sort("date", DESCENDING).limit(1))
|
||||
return _doc_summary(docs[0]) if docs else None
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def open_email_by_subject(subject: str) -> dict:
|
||||
"""Vyhledá v OWA podle subjectu a otevře první výsledek v reading pane."""
|
||||
return WORKER.call(_open_by_subject, subject)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def forward_current(body_prefix: str = "", subject_prefix: str = "") -> dict:
|
||||
"""Klikne Forward (Ctrl+Shift+F). Pokud je `body_prefix`, vepíše ho na začátek body.
|
||||
Pokud `subject_prefix`, předřadí ho do předmětu draftu."""
|
||||
return WORKER.call(_forward, body_prefix, subject_prefix)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def write_at_top(text: str) -> dict:
|
||||
"""Vepíše text na začátek body otevřeného draftu (před existující obsah / podpis)."""
|
||||
return WORKER.call(_write_at_top, text)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_recipients(to: list[str], cc: Optional[list[str]] = None) -> dict:
|
||||
"""Vyplní To / Cc v otevřeném draftu."""
|
||||
return WORKER.call(_set_recipients, to, cc)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def close_reading_pane() -> dict:
|
||||
"""Zavře otevřený email v reading pane (Escape). Forward draft tím nezavře."""
|
||||
return WORKER.call(_close_pane)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def screenshot(path: str = "owa_screenshot.png") -> dict:
|
||||
"""Uloží screenshot aktivního okna pro orientaci."""
|
||||
return WORKER.call(_screenshot, path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
log("mcp_owa v1.2 starting (stdio)…")
|
||||
mcp.run()
|
||||
Reference in New Issue
Block a user