From f633ec47884b6c3c7797f158a7faea1c8427c973 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 10 Jul 2026 15:44:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(demo):=20gdocs=20agent=20=E2=80=94=20polic?= =?UTF-8?q?y-gated=20Google-Docs=20MCP=20agent=20on=20the=20HexKit=20contr?= =?UTF-8?q?act?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new backend (demo/gdocs-agent/) serving one hexgate agent over the five contract endpoints, for the gates demo. It connects to a fake Google-Docs MCP server (its tools land under mcp-gdocs-*), and binds its policy from the hexgate platform (create_agent(name="docs_agent", bind_policy=True)) so the dashboard is the single source of truth and edits hot-reload. The caller's HexKit role (analyst/editor/admin) drives allow/deny; a blocked call streams as a hexgate error event that HexgateTranslator renders in the tool-calls widget. - app.py: 5 contract endpoints + an MCP-toolset lifespan (keeps the gdocs stdio connection open process-wide) + POST /byok (in-memory OpenAI key handoff). - agent.py: platform-bound agent build (off the event loop) + role scoping + BYOK. - gdocs_mcp_server.py, ui/docs.yaml, __main__.py (serves on :8880). - demo-users.yaml: analyst/editor/admin users matching the policy vocabulary. --- demo/gdocs-agent/README.md | 63 +++++++ demo/gdocs-agent/demo-users.yaml | 37 ++++ demo/gdocs-agent/pyproject.toml | 23 +++ demo/gdocs-agent/src/gdocs_agent/__init__.py | 1 + demo/gdocs-agent/src/gdocs_agent/__main__.py | 26 +++ demo/gdocs-agent/src/gdocs_agent/agent.py | 176 ++++++++++++++++++ demo/gdocs-agent/src/gdocs_agent/app.py | 172 +++++++++++++++++ .../src/gdocs_agent/gdocs_mcp_server.py | 59 ++++++ demo/gdocs-agent/src/gdocs_agent/ui/docs.yaml | 27 +++ 9 files changed, 584 insertions(+) create mode 100644 demo/gdocs-agent/README.md create mode 100644 demo/gdocs-agent/demo-users.yaml create mode 100644 demo/gdocs-agent/pyproject.toml create mode 100644 demo/gdocs-agent/src/gdocs_agent/__init__.py create mode 100644 demo/gdocs-agent/src/gdocs_agent/__main__.py create mode 100644 demo/gdocs-agent/src/gdocs_agent/agent.py create mode 100644 demo/gdocs-agent/src/gdocs_agent/app.py create mode 100644 demo/gdocs-agent/src/gdocs_agent/gdocs_mcp_server.py create mode 100644 demo/gdocs-agent/src/gdocs_agent/ui/docs.yaml diff --git a/demo/gdocs-agent/README.md b/demo/gdocs-agent/README.md new file mode 100644 index 0000000..67ee95c --- /dev/null +++ b/demo/gdocs-agent/README.md @@ -0,0 +1,63 @@ +# gdocs-agent — a policy-gated Google-Docs agent on the HexKit contract + +The "gates" demo backend. It serves **one** hexgate agent (`docs`) over the five +[CONTRACT.md](../../CONTRACT.md) endpoints. The agent: + +- connects to a **fake Google Docs MCP server** (spawned over stdio by the app + lifespan) and inherits its six tools under the `mcp-gdocs-*` namespace; +- is created with `bind_policy=True` + `name="docs_agent"`, so its policy is + **fetched from the hexgate platform** and hot-reloaded every run — edit it in + the dashboard's **Policies** tab and the next message reflects it; +- runs under the caller's HexKit **role** (`analyst` / `editor` / `admin`) via + `async with hexgate.User(role=...)`, so the same call is allowed for one role + and denied for another. A denied tool call streams as a hexgate `error` event + and shows up as a failed call in the tool-calls widget. + +This is the *runtime* side of the demo. The *definition* side — the agent code, +a diagram, and the policy — lives in the hexgate repo at +`asianf/deploy/gates-demo/notebook.py`. + +## Prerequisites + +- **Python ≥ 3.13** (hexgate requires it; `hexgate.mcp` spawns the MCP server). +- **A hexgate platform to bind to.** Set `HEXGATE_API_URL` + `HEXGATE_API_KEY` + for a project whose `docs_agent` policy is seeded (the one-box demo does this + automatically — see `asianf/deploy/`). Without a reachable platform the first + run errors (fail-loud), by design. +- **`OPENAI_API_KEY`** — read from this process's env, or handed in-memory via + `POST /byok {"openai_key": "sk-..."}` (the demo notebook posts it). Kept in + process memory only — never persisted. HexKit never sends provider keys. + +## Run it + +```bash +uv venv --python 3.13 demo/gdocs-agent/.venv +uv pip install --python demo/gdocs-agent/.venv -e 'demo/gdocs-agent[dev]' + +export HEXGATE_API_URL=http://127.0.0.1:8000 # your platform +export HEXGATE_API_KEY=fty_live_... # or drop /tmp/hexgate_serve_key +export OPENAI_API_KEY=sk-... + +demo/gdocs-agent/.venv/bin/python -m gdocs_agent # serves on :8880 +``` + +`:8880` is the port the HexKit proxy proxies by default +(`PLATFORM_AGENT_BACKEND_URL`), so this backend stands in as the demo's only +agent server with no proxy config. Point HexKit's front-app + proxy at it and +the picker shows one agent, **Docs Assistant**. + +## Verify the contract + +```bash +demo/gdocs-agent/.venv/bin/python demo/scripts/verify_backend.py http://127.0.0.1:8880 +``` + +## Environment + +| var | purpose | +|---|---| +| `HEXGATE_API_URL` / `HEXGATE_API_KEY` | platform to bind `docs_agent`'s policy from | +| `HEXGATE_SERVE_KEY_FILE` | file the demo boot writes the minted key to (default `/tmp/hexgate_serve_key`); adopted into `HEXGATE_API_KEY` if unset | +| `OPENAI_API_KEY` | BYOK — the chat model's key (env, or posted in-memory to `/byok`) | +| `HEXGATE_MODEL` | chat model (default `gpt-4o-mini`) | +| `AGENT_HOST` / `AGENT_PORT` | bind address (default `127.0.0.1:8880`) | diff --git a/demo/gdocs-agent/demo-users.yaml b/demo/gdocs-agent/demo-users.yaml new file mode 100644 index 0000000..bd08523 --- /dev/null +++ b/demo/gdocs-agent/demo-users.yaml @@ -0,0 +1,37 @@ +# Demo users for the gates demo (the gdocs agent). Point the proxy at this with +# PLATFORM_DEMO_USERS_FILE so switching login shows the same call allowed for +# one role and denied for another. +# +# `role` is opaque to HexKit — the gdocs agent reads it via User(role=...) and +# the platform's docs_agent policy resolves the rules. The three roles below +# match that policy: analyst (read-only) < editor (create/share internal) < +# admin (everything, with guardrails). `agents: [docs]` scopes each user to the +# one agent this backend serves. +# +# Password is `hexademo` for everyone. DO NOT commit real credentials. +users: + + # No role → exercises the policy's fail-closed default (deny) path. + - email: guest@example.com + password: hexademo + name: Sam Guest + agents: [docs] + # role omitted on purpose + + - email: ana@hexamind.ai + password: hexademo + name: Ana Analyst # read-only: search + read (not CONF-* docs) + role: analyst + agents: [docs] + + - email: ed@hexamind.ai + password: hexademo + name: Ed Editor # + create, share inside @hexamind.ai (<=5, not owner) + role: editor + agents: [docs] + + - email: adah@hexamind.ai + password: hexademo + name: Adah Admin # everything: read CONF-*, export to internal drive, delete (confirm) + role: admin + agents: [docs] diff --git a/demo/gdocs-agent/pyproject.toml b/demo/gdocs-agent/pyproject.toml new file mode 100644 index 0000000..33d2d65 --- /dev/null +++ b/demo/gdocs-agent/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "gdocs-agent" +version = "0.1.0" +description = "HexKit backend serving a hexgate-gated Google-Docs MCP agent — policy lives on the hexgate platform; denials stream as hexgate events (framework='hexgate')" +# hexgate requires >=3.13, and hexgate.mcp pulls in the MCP client used to spawn +# the fake Google Docs server. Create this venv with a 3.13+ interpreter. +requires-python = ">=3.13" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "hexgate>=0.2.8", # >=0.2.8 has hexgate.mcp (the MCP gate) + "langchain-openai>=0.2", # the chat model the agent drives +] + +[project.optional-dependencies] +dev = ["httpx>=0.27", "ruff>=0.6"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/gdocs_agent"] diff --git a/demo/gdocs-agent/src/gdocs_agent/__init__.py b/demo/gdocs-agent/src/gdocs_agent/__init__.py new file mode 100644 index 0000000..e658150 --- /dev/null +++ b/demo/gdocs-agent/src/gdocs_agent/__init__.py @@ -0,0 +1 @@ +"""HexKit backend serving the hexgate-gated Google-Docs MCP agent.""" diff --git a/demo/gdocs-agent/src/gdocs_agent/__main__.py b/demo/gdocs-agent/src/gdocs_agent/__main__.py new file mode 100644 index 0000000..8880c28 --- /dev/null +++ b/demo/gdocs-agent/src/gdocs_agent/__main__.py @@ -0,0 +1,26 @@ +"""Console entrypoint: ``python -m gdocs_agent``. + +Defaults to :8880 — the port the HexKit proxy proxies by default +(``PLATFORM_AGENT_BACKEND_URL``), so this backend can stand in as the demo's +only agent server with no proxy config. Override with AGENT_HOST / AGENT_PORT. +""" + +from __future__ import annotations + +import os + +import uvicorn + +from .app import create_app + +app = create_app() + + +def main() -> None: + host = os.getenv("AGENT_HOST", "127.0.0.1") + port = int(os.getenv("AGENT_PORT", "8880")) + uvicorn.run(app, host=host, port=port) + + +if __name__ == "__main__": + main() diff --git a/demo/gdocs-agent/src/gdocs_agent/agent.py b/demo/gdocs-agent/src/gdocs_agent/agent.py new file mode 100644 index 0000000..1cd81e5 --- /dev/null +++ b/demo/gdocs-agent/src/gdocs_agent/agent.py @@ -0,0 +1,176 @@ +"""The gdocs agent and its event forwarder. + +This backend serves ONE hexgate agent — ``docs_agent`` — over the HexKit +contract. Two things make it the "gates" demo: + +1. **Its tools come from an MCP server it doesn't own.** The agent connects to a + fake Google Docs MCP server (spawned over stdio by the app lifespan) and + inherits all six of its tools under the ``mcp-gdocs-*`` namespace. We gate + them; we don't edit them. + +2. **Its policy lives on the hexgate platform, not here.** The agent is created + with ``bind_policy=True`` + ``name="docs_agent"``, so hexgate fetches the + policy for ``docs_agent`` from the platform (``HEXGATE_API_URL`` with + ``HEXGATE_API_KEY``) and hot-reloads it on every run. Edit the policy in the + dashboard's **Policies** tab and the next message here reflects it — no + redeploy. A blocked call surfaces as a hexgate ``error`` event, which the + proxy's ``HexgateTranslator`` renders in the tool-calls widget. + +The caller's HexKit role (``context.user.role`` — analyst / editor / admin) +drives which rules apply, via ``async with hexgate.User(role=...)``. + +BYOK: the OpenAI key is never sent by HexKit. It's read from this process's env +(``OPENAI_API_KEY``) or handed in-memory to ``POST /byok`` (the demo notebook +posts it when the visitor pastes their key). It lives only in this throwaway +process's memory and is never persisted or logged. +""" + +from __future__ import annotations + +import asyncio +import os +from collections.abc import AsyncIterator +from typing import Any + +_DEFAULT_MODEL = os.getenv("HEXGATE_MODEL", "gpt-4o-mini") + +SYSTEM_PROMPT = ( + "You are a Google Docs assistant for the team. You can search, read, create, " + "share, export, and delete docs by calling the gdocs tools — always use a " + "tool rather than guessing. Some actions may be blocked by policy depending " + "on the user's role; if a tool call is denied, tell the user plainly what was " + "blocked and why, and suggest an allowed alternative." +) + +# Set by the app lifespan (see app.py) once the MCP toolset is connected: the +# live LangChain tools for the fake Google Docs server. None until then. +_mcp_tools: list[Any] | None = None + +# BYOK: the visitor's OpenAI key, handed to this process in-memory via the +# POST /byok endpoint (the demo notebook posts it). Never written to disk. +_runtime_openai_key: str | None = None + + +def set_mcp_tools(tools: list[Any]) -> None: + """Called by the app lifespan with the connected MCP toolset's tools.""" + global _mcp_tools + _mcp_tools = tools + + +def set_openai_key(key: str | None) -> None: + """Store the visitor's OpenAI key in process memory (BYOK). Never persisted.""" + global _runtime_openai_key + _runtime_openai_key = (key or "").strip() or None + + +def _resolve_openai_key() -> str | None: + """BYOK: this process's env, else the in-memory key posted to /byok.""" + return os.getenv("OPENAI_API_KEY") or _runtime_openai_key + + +# Built agents cached by (model, api_key) — no per-conversation state (full +# history is resent each turn), so one build is reused. Keyed on the OpenAI key +# so rotating it rebuilds. Memory-only; never persisted. +_agent_cache: dict[tuple[str, str], tuple[Any, Any]] = {} + + +def _get_agent(api_key: str) -> tuple[Any, Any]: + """Return a cached (agent, handler), building + platform-binding on miss. + + ``create_agent(bind_policy=True, name="docs_agent")`` fetches the + ``docs_agent`` policy from the platform at build time and attaches a refresh + source, so the returned agent is already gated and hot-reloads per run. + """ + cache_key = (_DEFAULT_MODEL, api_key) + cached = _agent_cache.get(cache_key) + if cached is not None: + return cached + + if _mcp_tools is None: + raise RuntimeError( + "MCP toolset not connected — the gdocs server failed to start (see the app lifespan)." + ) + + # Imported lazily: hexgate pulls in heavy deps at import, so the /agents + # roster stays cheap and we pay it only when a run needs the SDK. + from hexgate import create_agent + from langchain_openai import ChatOpenAI + + model = ChatOpenAI(model=_DEFAULT_MODEL, api_key=api_key, temperature=0) + built = create_agent( + model=model, + tools=_mcp_tools, + system_prompt=SYSTEM_PROMPT, + name="docs_agent", # the policy lookup key on the platform + bind_policy=True, # fetch + enforce the platform policy, hot-reloaded + ) + _agent_cache[cache_key] = built + return built + + +def _messages_with_files(input: dict[str, Any], context: dict[str, Any]) -> list[dict[str, Any]]: + """Forward the transcript, inlining any attached files as a system preface.""" + messages = list((input or {}).get("messages") or []) + files = (context or {}).get("files") or [] + if not files: + return messages + listing = "\n\n".join( + f"### {f.get('name')} ({f.get('mime')})\n{f.get('content') or '[binary file omitted]'}" + for f in files + ) + preface = {"role": "system", "content": f"Attached files:\n\n{listing}"} + return [preface, *messages] + + +async def run_gdocs_agent( + *, input: dict[str, Any], context: dict[str, Any], cancel: asyncio.Event +) -> AsyncIterator[dict]: + """Stream one run as hexgate-native events (JSON-projected dicts). + + Yields each ``hexgate.StreamEvent`` as ``model_dump(mode="json")`` — the + shape the proxy's HexgateTranslator reads. The caller tags each frame with + ``framework: "hexgate"``. + """ + api_key = _resolve_openai_key() + if not api_key: + yield { + "event_type": "error", + "message": ( + "No OpenAI API key available. Paste your key in the demo " + "notebook (or set OPENAI_API_KEY in this backend's env)." + ), + } + return + + from hexgate import User + from hexgate.agents.factory import stream_agent + + try: + # Build + platform-bind off the event loop: create_agent(bind_policy=True) + # makes a synchronous HTTP round-trip to the platform (first call per key). + agent, handler = await asyncio.to_thread(_get_agent, api_key) + except Exception as exc: # noqa: BLE001 — surface as a hexgate error event + yield {"event_type": "error", "message": str(exc)} + return + + messages = _messages_with_files(input, context) + + # Bind the run to the HexKit caller's identity — hexgate reads this + # ContextVar to resolve the role's rules from the platform policy. No user + # block = unscoped (falls through to the policy's fail-closed default). + caller = (context or {}).get("user") or {} + user_id = caller.get("id") + role = caller.get("role") + + if user_id is None: + async for event in stream_agent(agent, handler, {"messages": messages}): + if cancel.is_set(): + return + yield event.model_dump(mode="json") + return + + async with User(user_id=user_id, role=role): + async for event in stream_agent(agent, handler, {"messages": messages}): + if cancel.is_set(): + return + yield event.model_dump(mode="json") diff --git a/demo/gdocs-agent/src/gdocs_agent/app.py b/demo/gdocs-agent/src/gdocs_agent/app.py new file mode 100644 index 0000000..48623e8 --- /dev/null +++ b/demo/gdocs-agent/src/gdocs_agent/app.py @@ -0,0 +1,172 @@ +"""The five HexKit contract endpoints for the hexgate-gated gdocs agent. + +Structurally the same as demo/hexgate-agent/ — five endpoints, SSE framing, +``framework: "hexgate"`` so the proxy selects ``HexgateTranslator`` — with one +addition: a **lifespan** that connects the fake Google Docs MCP server once at +startup and keeps the stdio connection open for the whole process, so the +agent's ``mcp-gdocs-*`` tools can execute across many runs. See CONTRACT.md. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import sys +import uuid +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, FastAPI, HTTPException, Request +from fastapi.responses import Response, StreamingResponse + +from .agent import run_gdocs_agent, set_mcp_tools, set_openai_key + +_UI_DIR = Path(__file__).parent / "ui" +_MCP_SERVER = Path(__file__).parent / "gdocs_mcp_server.py" + +# §3 — the GET /agents roster. `framework: "hexgate"` points the proxy at +# HexgateTranslator; the id `docs` is what HexKit shows in the picker. +AGENTS: list[dict[str, str]] = [ + { + "id": "docs", + "name": "Docs Assistant", + "role": "Google-Docs agent (policy-gated)", + "main_color": "#2563eb", + "ui_url": "/agents/docs/ui", + "framework": "hexgate", + }, +] +_BY_ID = {a["id"]: a for a in AGENTS} + + +router = APIRouter(prefix="/agents", tags=["agents"]) + + +@router.get("") # §3 +async def list_agents() -> list[dict[str, str]]: + return AGENTS + + +@router.get("/{agent_id}/ui") # §4 +async def get_ui(agent_id: str) -> Response: + if agent_id not in _BY_ID: + raise HTTPException(status_code=404, detail=f"Unknown agent '{agent_id}'") + path = _UI_DIR / f"{agent_id}.yaml" + if not path.is_file(): + raise HTTPException(status_code=404, detail="No ui.yaml for this agent") + return Response(content=path.read_text(encoding="utf-8"), media_type="text/yaml") + + +@router.post("/{agent_id}/stream") # §5 +async def stream(agent_id: str, body: dict[str, Any], request: Request): + if agent_id not in _BY_ID: + raise HTTPException(status_code=404, detail=f"Unknown agent '{agent_id}'") + + run_id = body.get("run_id") or uuid.uuid4().hex + input = body.get("input") or {} + context = body.get("context") or {} + framework = _BY_ID[agent_id]["framework"] + + cancel = asyncio.Event() + request.app.state.runs[run_id] = cancel + + async def event_source() -> AsyncIterator[bytes]: + try: + async for ev in run_gdocs_agent(input=input, context=context, cancel=cancel): + if cancel.is_set() or await request.is_disconnected(): + return + frame = {"framework": framework, "event": ev} + yield f"data: {json.dumps(frame, separators=(',', ':'))}\n\n".encode() + except Exception as e: # surface failures as a hexgate error event + err = { + "framework": framework, + "event": {"event_type": "error", "message": str(e)}, + } + yield f"data: {json.dumps(err)}\n\n".encode() + finally: + request.app.state.runs.pop(run_id, None) + + return StreamingResponse(event_source(), media_type="text/event-stream") + + +@router.post("/{agent_id}/cancel") # §5 +async def cancel(agent_id: str, body: dict[str, Any], request: Request) -> dict: + run_id = (body or {}).get("run_id") + ev = request.app.state.runs.get(run_id) if run_id else None + if ev is None: + return {"cancelled": False} + ev.set() + return {"cancelled": True} + + +@router.post("/{agent_id}/actions/{action_name}") # §5b (optional) +async def invoke_action( + agent_id: str, action_name: str, body: dict[str, Any] | None = None +) -> dict: + """This agent's ui.yaml is chat-only, so there are no real actions — echo + for parity with the contract (see CONTRACT.md §5b).""" + if agent_id not in _BY_ID: + raise HTTPException(status_code=404, detail=f"Unknown agent '{agent_id}'") + args = (body or {}).get("args", {}) + return {"result": {"action": action_name, "args": args}} + + +def _adopt_platform_key() -> None: + """Make this process able to bind the platform policy for docs_agent. + + The demo boot writes the minted key to a file; adopt it into + ``HEXGATE_API_KEY`` (without clobbering an explicit env key) and default the + API URL to the local platform. Harmless no-op outside the one-box demo. + """ + if not os.getenv("HEXGATE_API_KEY"): + key_file = Path(os.getenv("HEXGATE_SERVE_KEY_FILE", "/tmp/hexgate_serve_key")) + if key_file.exists() and (key := key_file.read_text().strip()): + os.environ["HEXGATE_API_KEY"] = key + os.environ.setdefault("HEXGATE_API_URL", "http://127.0.0.1:8000") + # Opt in to platform binding for auto-bind paths; bind_policy=True already + # forces it, but setting this keeps behavior explicit. + os.environ.setdefault("HEXGATE_BIND_AGENTS", "1") + + +@contextlib.asynccontextmanager +async def _lifespan(app: FastAPI) -> AsyncIterator[None]: + """Connect the fake Google Docs MCP server once; keep it open process-wide. + + The toolset's stdio connection must stay open for the agent's tools to + execute, so we enter it here and hold it for the app's lifetime. + """ + _adopt_platform_key() + from hexgate.mcp import MCPServerConfig, MCPToolset + + cfg = MCPServerConfig( + name="gdocs", + transport="stdio", + command=sys.executable, + args=(str(_MCP_SERVER),), + ) + async with MCPToolset(cfg) as mcp: + set_mcp_tools(mcp.tools) + yield + + +def create_app() -> FastAPI: + app = FastAPI(title="HexKit gdocs agent", version="0.1.0", lifespan=_lifespan) + app.state.runs = {} + app.include_router(router) + + @app.get("/") + async def root() -> dict: + return {"service": "gdocs-agent", "agents": "/agents"} + + # BYOK key handoff — NOT part of the HexKit contract. The demo notebook posts + # the visitor's OpenAI key here (same container) so this backend can run the + # model. Held in process memory only; never persisted or logged. + @app.post("/byok") + async def byok(body: dict[str, Any]) -> dict: + set_openai_key((body or {}).get("openai_key")) + return {"ok": True} + + return app diff --git a/demo/gdocs-agent/src/gdocs_agent/gdocs_mcp_server.py b/demo/gdocs-agent/src/gdocs_agent/gdocs_mcp_server.py new file mode 100644 index 0000000..378f28b --- /dev/null +++ b/demo/gdocs-agent/src/gdocs_agent/gdocs_mcp_server.py @@ -0,0 +1,59 @@ +"""Fake Google Docs MCP server — the third-party tool surface this agent gates. + +A self-contained fixture (mirrors asianf/deploy/gates-demo/gdocs_mcp_server.py) +so this backend runs without the hexgate SDK repo checked out beside it. The +backend spawns it over stdio; the *policy* that gates it lives on the hexgate +platform (see this folder's README), not here. +""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +server = FastMCP("gdocs") + +_DOCS = { + "DOC-101": {"title": "Q3 launch plan", "body": "Ship the gate on the 14th."}, + "DOC-102": {"title": "Onboarding checklist", "body": "Laptop, badge, VPN."}, + "CONF-900": {"title": "Acquisition terms", "body": "Project Falcon — $42M."}, +} + + +@server.tool(description="Search docs by a keyword in the title. Read-only.") +def search_docs(query: str) -> str: + hits = [ + f"{doc_id}: {d['title']}" + for doc_id, d in _DOCS.items() + if query.lower() in d["title"].lower() + ] + return "\n".join(hits) if hits else f"no docs match {query!r}" + + +@server.tool(description="Read a document's full body by id.") +def read_doc(doc_id: str) -> str: + doc = _DOCS.get(doc_id) + return f"{doc['title']}\n\n{doc['body']}" if doc else f"no such doc: {doc_id}" + + +@server.tool(description="Create a new doc in a folder. Returns the new id.") +def create_doc(title: str, folder: str = "Drafts") -> str: + return f"created '{title}' in {folder} — id=DOC-{len(_DOCS) + 100}" + + +@server.tool(description="Share a doc with recipients at a given role (viewer/editor/owner).") +def share_doc(doc_id: str, recipients: list[str], role: str = "viewer") -> str: + return f"shared {doc_id} with {len(recipients)} recipient(s) as {role}" + + +@server.tool(description="Export a doc by POSTing it to an external URL.") +def export_doc(doc_id: str, url: str) -> str: + return f"exported {doc_id} to {url}" + + +@server.tool(description="Permanently delete a doc. Destructive — needs confirm=true.") +def delete_doc(doc_id: str, confirm: bool = False) -> str: + return f"deleted {doc_id}" if confirm else f"refused: pass confirm=true to delete {doc_id}" + + +if __name__ == "__main__": + server.run("stdio") diff --git a/demo/gdocs-agent/src/gdocs_agent/ui/docs.yaml b/demo/gdocs-agent/src/gdocs_agent/ui/docs.yaml new file mode 100644 index 0000000..b67a920 --- /dev/null +++ b/demo/gdocs-agent/src/gdocs_agent/ui/docs.yaml @@ -0,0 +1,27 @@ +# Docs Assistant — served by GET /agents/docs/ui. +# The tool-calls widget is where the gate shows up: every mcp-gdocs-* call +# appears here, and a policy-denied call renders as a failed tool call. +page: + layout_type: grid + main_color: "#2563eb" + +widgets: + - name: transcript + type: ai-response + position: { horizontal: left, vertical: high } + size: { width: 8, height: 520 } + empty_text: "Ask the Docs Assistant — try \"share the launch plan with dana@hexamind.ai\", then try an outside address." + thinking_indicator: dots + + - name: tools + type: tool-calls + position: { horizontal: right, vertical: high } + size: { width: 4, height: 520 } + empty_text: "Tool calls appear here — allowed ones run; denied ones show the policy block." + + - name: chat-input + type: ai-chat-input + position: { horizontal: left, vertical: low } + size: { width: 12, height: auto } + placeholder: "Message the Docs Assistant…" + rows: 2