argile-mcp 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- argile_mcp-0.1.1/.gitignore +9 -0
- argile_mcp-0.1.1/PKG-INFO +10 -0
- argile_mcp-0.1.1/README.md +139 -0
- argile_mcp-0.1.1/examples/claude_desktop_config.json +10 -0
- argile_mcp-0.1.1/examples/mcp.json +10 -0
- argile_mcp-0.1.1/examples/smoke.py +44 -0
- argile_mcp-0.1.1/pyproject.toml +22 -0
- argile_mcp-0.1.1/src/argile_mcp/__init__.py +8 -0
- argile_mcp-0.1.1/src/argile_mcp/auth.py +66 -0
- argile_mcp-0.1.1/src/argile_mcp/client.py +145 -0
- argile_mcp-0.1.1/src/argile_mcp/config.py +33 -0
- argile_mcp-0.1.1/src/argile_mcp/credentials.py +25 -0
- argile_mcp-0.1.1/src/argile_mcp/errors.py +2 -0
- argile_mcp-0.1.1/src/argile_mcp/schemas.py +152 -0
- argile_mcp-0.1.1/src/argile_mcp/server.py +141 -0
- argile_mcp-0.1.1/src/argile_mcp/sse.py +52 -0
- argile_mcp-0.1.1/src/argile_mcp/telemetry.py +45 -0
- argile_mcp-0.1.1/tests/conftest.py +47 -0
- argile_mcp-0.1.1/tests/test_auth.py +99 -0
- argile_mcp-0.1.1/tests/test_client.py +223 -0
- argile_mcp-0.1.1/tests/test_contract.py +166 -0
- argile_mcp-0.1.1/tests/test_server.py +115 -0
- argile_mcp-0.1.1/tests/test_telemetry.py +60 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: argile-mcp
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: MCP server to talk with the Argile lead bot from Claude
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: httpx>=0.28
|
|
7
|
+
Requires-Dist: logfire[httpx]>=4
|
|
8
|
+
Requires-Dist: mcp>=1.2
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.7
|
|
10
|
+
Requires-Dist: pydantic>=2.7
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# argile-mcp
|
|
2
|
+
|
|
3
|
+
MCP server to talk with the Argile lead bot directly from Claude (Claude Code, Claude Desktop, or any MCP client).
|
|
4
|
+
|
|
5
|
+
It wraps the bot's web chat API (`https://bot.argile.ai`) so Claude can hold conversations with the bot — which in turn creates leads, quotes, catalogs and projects in the Argile CRM on your behalf.
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | Description |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `chat(message, conversation_id?)` | Send a message to the bot, get the full reply. Omit `conversation_id` to start a new conversation; reuse the returned id to continue it. |
|
|
12
|
+
| `list_conversations(limit?)` | List your recent conversations (title, counts, timestamps). |
|
|
13
|
+
| `get_conversation(conversation_id)` | Full transcript + linked CRM objects (leads, quotes, …) with direct-access URLs. |
|
|
14
|
+
| `clear_history()` | Clear the active conversation context (destructive). |
|
|
15
|
+
|
|
16
|
+
## Logging in
|
|
17
|
+
|
|
18
|
+
No token copying needed — the server ships a device-flow login that reuses
|
|
19
|
+
the bot's magic-link page:
|
|
20
|
+
|
|
21
|
+
- **From Claude**: just ask to talk to the bot; if you're not logged in, Claude
|
|
22
|
+
calls `start_login`, gives you a link to open (you sign in with your Argile
|
|
23
|
+
account and click *Autoriser*), then `complete_login` stores the token in
|
|
24
|
+
`~/.argile/credentials.json`.
|
|
25
|
+
- **From a terminal**: `uvx … argile-mcp login` (or `uv run argile-mcp login`
|
|
26
|
+
from a checkout) prints the link and waits for you to authorize.
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
| Env var | Required | Description |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `ARGILE_TOKEN` | no | Argile API bearer token; overrides the stored login. Useful for CI. |
|
|
33
|
+
| `ARGILE_BOT_URL` | no | Bot base URL, defaults to `https://bot.argile.ai`. |
|
|
34
|
+
| `ARGILE_CREDENTIALS_PATH` | no | Where the login flow stores the token, defaults to `~/.argile/credentials.json`. |
|
|
35
|
+
| `ARGILE_LOG_LEVEL` | no | Stderr log level of the server process, defaults to `INFO`. |
|
|
36
|
+
| `LOGFIRE_TOKEN` | no | Set to export traces to [Logfire](https://logfire.pydantic.dev) (MCP tool calls, bot HTTP calls, chat spans). Unset → no telemetry leaves the machine. |
|
|
37
|
+
|
|
38
|
+
## Logging & telemetry
|
|
39
|
+
|
|
40
|
+
stdout carries the MCP protocol, so the server only ever logs to **stderr**.
|
|
41
|
+
With `LOGFIRE_TOKEN` set (we use it internally; end users don't need it),
|
|
42
|
+
Logfire traces every MCP tool call and bot HTTP request, plus an
|
|
43
|
+
`argile-bot chat` span per turn with `tools_used` / `reply_chars` /
|
|
44
|
+
`chart_count` attributes. Logs and spans never contain message or reply
|
|
45
|
+
content — only sizes, ids and tool names.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
Requires [uv](https://docs.astral.sh/uv/). The server runs on stdio and is published to PyPI as [`argile-mcp`](https://pypi.org/project/argile-mcp/) (see *Releasing* below).
|
|
50
|
+
|
|
51
|
+
### Claude Code
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
claude mcp add argile-bot -- uvx argile-mcp
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or per-project, in `.mcp.json` (see [`examples/mcp.json`](examples/mcp.json)):
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"mcpServers": {
|
|
62
|
+
"argile-bot": {
|
|
63
|
+
"command": "uvx",
|
|
64
|
+
"args": ["argile-mcp"]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Claude Desktop
|
|
71
|
+
|
|
72
|
+
Add the same server block to `~/Library/Application Support/Claude/claude_desktop_config.json` (see [`examples/claude_desktop_config.json`](examples/claude_desktop_config.json)).
|
|
73
|
+
|
|
74
|
+
### From a local checkout (development)
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
claude mcp add argile-bot \
|
|
78
|
+
-- uv run --directory /path/to/argile-bot/mcp argile-mcp
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Example prompts
|
|
82
|
+
|
|
83
|
+
Once installed, just ask Claude:
|
|
84
|
+
|
|
85
|
+
- *"Demande au bot Argile de lister mes conversations récentes."*
|
|
86
|
+
- *"Via le bot Argile : crée un lead pour Marie Dupont, 12 rue des Lilas à Lyon, projet pompe à chaleur."*
|
|
87
|
+
- *"Reprends la conversation `<id>` avec le bot et demande où en est le devis."*
|
|
88
|
+
- *"Montre-moi le transcript de ma dernière conversation avec le bot et les objets CRM liés."*
|
|
89
|
+
|
|
90
|
+
A typical `chat` result:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"reply": "J'ai créé le lead Marie Dupont ✅ …",
|
|
95
|
+
"conversation_id": "0e8f7c3a-…",
|
|
96
|
+
"active_lead_id": "lead_123",
|
|
97
|
+
"tools_used": ["create_lead"],
|
|
98
|
+
"chart_count": 0
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
The package is a uv workspace member; its tests live next to it in
|
|
105
|
+
`mcp/tests/` (also collected by a plain `uv run pytest` from the repo root)
|
|
106
|
+
and include contract tests that bind the MCP client to the real chat router,
|
|
107
|
+
so API drift fails CI.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
uv sync
|
|
111
|
+
uv run pytest mcp/tests
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Smoke-test against the real bot (after `argile-mcp login` or with `ARGILE_TOKEN` set):
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
uv run python mcp/examples/smoke.py "Bonjour, qui es-tu ?"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Inspect the server interactively with the MCP Inspector:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npx @modelcontextprotocol/inspector uv run argile-mcp
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Releasing
|
|
127
|
+
|
|
128
|
+
Bump `version` in `mcp/pyproject.toml`, then push a matching tag:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
git tag mcp-v0.1.0 && git push origin mcp-v0.1.0
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The `release-mcp.yml` workflow runs the MCP tests, builds the package and
|
|
135
|
+
publishes it to PyPI via trusted publishing. One-time setup on pypi.org:
|
|
136
|
+
project **argile-mcp** → *Publishing* → add the GitHub publisher
|
|
137
|
+
(`argile-ai/argile-bot`, workflow `release-mcp.yml`, environment `pypi`),
|
|
138
|
+
and create the matching `pypi` environment in this repo's GitHub settings.
|
|
139
|
+
Only the built wheel goes public — the repo stays private.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Smoke test against the real bot: one chat round-trip through the MCP server.
|
|
2
|
+
|
|
3
|
+
Usage (from the repo root, after `argile-mcp login` or with ARGILE_TOKEN set):
|
|
4
|
+
uv run python mcp/examples/smoke.py "Bonjour, qui es-tu ?"
|
|
5
|
+
|
|
6
|
+
Spawns the MCP server as a subprocess over stdio (exactly how Claude runs it),
|
|
7
|
+
lists its tools, sends one chat message and prints the structured reply.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from mcp import ClientSession, StdioServerParameters
|
|
16
|
+
from mcp.client.stdio import stdio_client
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def main() -> None:
|
|
20
|
+
message = sys.argv[1] if len(sys.argv) > 1 else "Bonjour, qui es-tu ?"
|
|
21
|
+
|
|
22
|
+
params = StdioServerParameters(
|
|
23
|
+
command="uv", args=["run", "argile-mcp"], env=dict(os.environ)
|
|
24
|
+
)
|
|
25
|
+
async with stdio_client(params) as (read, write):
|
|
26
|
+
async with ClientSession(read, write) as session:
|
|
27
|
+
await session.initialize()
|
|
28
|
+
|
|
29
|
+
tools = (await session.list_tools()).tools
|
|
30
|
+
print(f"tools: {[tool.name for tool in tools]}")
|
|
31
|
+
|
|
32
|
+
print(f"> {message}")
|
|
33
|
+
result = await session.call_tool("chat", {"message": message})
|
|
34
|
+
if result.isError:
|
|
35
|
+
sys.exit(f"chat failed: {result.content[0].text}")
|
|
36
|
+
print(
|
|
37
|
+
json.dumps(
|
|
38
|
+
json.loads(result.content[0].text), indent=2, ensure_ascii=False
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "argile-mcp"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "MCP server to talk with the Argile lead bot from Claude"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"mcp>=1.2",
|
|
8
|
+
"httpx>=0.28",
|
|
9
|
+
"pydantic>=2.7",
|
|
10
|
+
"pydantic-settings>=2.7",
|
|
11
|
+
"logfire[httpx]>=4",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
argile-mcp = "argile_mcp.server:main"
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["src/argile_mcp"]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""MCP server for talking with the Argile lead bot from Claude."""
|
|
2
|
+
|
|
3
|
+
from argile_mcp.client import ArgileBotClient
|
|
4
|
+
from argile_mcp.config import Settings
|
|
5
|
+
from argile_mcp.errors import ArgileBotError
|
|
6
|
+
from argile_mcp.schemas import ChatResult
|
|
7
|
+
|
|
8
|
+
__all__ = ["ArgileBotClient", "ArgileBotError", "ChatResult", "Settings"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Device-flow login against the bot's /auth/mcp endpoints.
|
|
2
|
+
|
|
3
|
+
Mirrors the bot's magic-link flow: start a login session, send the user to
|
|
4
|
+
the returned URL to authorize in a browser (Clerk), then poll until the bot
|
|
5
|
+
hands out the token — exactly once.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from argile_mcp.errors import ArgileBotError
|
|
14
|
+
from argile_mcp.schemas import DeviceAuthStart
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
POLL_INTERVAL = 2.0
|
|
19
|
+
POLL_TIMEOUT = 180.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def start_login(
|
|
23
|
+
base_url: str, transport: httpx.AsyncBaseTransport | None = None
|
|
24
|
+
) -> DeviceAuthStart:
|
|
25
|
+
"""Open a login session; the user must authorize on the returned URL."""
|
|
26
|
+
async with httpx.AsyncClient(timeout=30.0, transport=transport) as client:
|
|
27
|
+
response = await client.post(f"{base_url.rstrip('/')}/auth/mcp/start")
|
|
28
|
+
if response.status_code != 200:
|
|
29
|
+
raise ArgileBotError(
|
|
30
|
+
f"HTTP {response.status_code} from bot: {response.text[:500]}"
|
|
31
|
+
)
|
|
32
|
+
start = DeviceAuthStart.model_validate(response.json())
|
|
33
|
+
logger.info("login session opened at %s", base_url)
|
|
34
|
+
return start
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def poll_token(
|
|
38
|
+
base_url: str,
|
|
39
|
+
poll_key: str,
|
|
40
|
+
timeout: float = POLL_TIMEOUT,
|
|
41
|
+
interval: float = POLL_INTERVAL,
|
|
42
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
43
|
+
) -> str:
|
|
44
|
+
"""Wait for the user to authorize, then collect the token (one-time)."""
|
|
45
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
46
|
+
async with httpx.AsyncClient(timeout=30.0, transport=transport) as client:
|
|
47
|
+
while True:
|
|
48
|
+
response = await client.get(
|
|
49
|
+
f"{base_url.rstrip('/')}/auth/mcp/token",
|
|
50
|
+
params={"poll_key": poll_key},
|
|
51
|
+
)
|
|
52
|
+
if response.status_code == 200:
|
|
53
|
+
logger.info("login authorized, token received")
|
|
54
|
+
return response.json()["token"]
|
|
55
|
+
if response.status_code != 202:
|
|
56
|
+
logger.warning("login poll failed: HTTP %d", response.status_code)
|
|
57
|
+
raise ArgileBotError(
|
|
58
|
+
f"Login failed (HTTP {response.status_code}): {response.text[:500]}"
|
|
59
|
+
)
|
|
60
|
+
if asyncio.get_running_loop().time() >= deadline:
|
|
61
|
+
logger.warning("login timed out after %.0fs", timeout)
|
|
62
|
+
raise ArgileBotError(
|
|
63
|
+
"Login not completed in time. Start a new login and open "
|
|
64
|
+
"the URL in your browser."
|
|
65
|
+
)
|
|
66
|
+
await asyncio.sleep(interval)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Thin async HTTP client over the Argile bot's web endpoints.
|
|
2
|
+
|
|
3
|
+
Logging never includes message or reply content (user data) — only sizes,
|
|
4
|
+
ids and tool names.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import logfire
|
|
12
|
+
from pydantic import TypeAdapter
|
|
13
|
+
|
|
14
|
+
from argile_mcp.config import Settings
|
|
15
|
+
from argile_mcp.credentials import load_token
|
|
16
|
+
from argile_mcp.errors import ArgileBotError
|
|
17
|
+
from argile_mcp.schemas import (
|
|
18
|
+
ChatResult,
|
|
19
|
+
ConversationDetail,
|
|
20
|
+
ConversationSummary,
|
|
21
|
+
StatusResult,
|
|
22
|
+
)
|
|
23
|
+
from argile_mcp.sse import fold_event, parse_sse_frame
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_SUMMARIES = TypeAdapter(list[ConversationSummary])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ArgileBotClient:
|
|
31
|
+
"""Client for the bot's chat and conversations API.
|
|
32
|
+
|
|
33
|
+
`transport` is injectable so tests can bind the client straight to the
|
|
34
|
+
bot's ASGI app without a network hop.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
base_url: str | None = None,
|
|
40
|
+
token: str | None = None,
|
|
41
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
42
|
+
):
|
|
43
|
+
settings = Settings()
|
|
44
|
+
self.base_url = (base_url or settings.argile_bot_url).rstrip("/")
|
|
45
|
+
self.token = (
|
|
46
|
+
token
|
|
47
|
+
or settings.argile_token
|
|
48
|
+
or load_token(settings.argile_credentials_path)
|
|
49
|
+
or ""
|
|
50
|
+
)
|
|
51
|
+
self.transport = transport
|
|
52
|
+
self.chat_timeout = httpx.Timeout(10.0, read=settings.chat_read_timeout)
|
|
53
|
+
if not self.token:
|
|
54
|
+
raise ArgileBotError(
|
|
55
|
+
"Not logged in. Use the start_login/complete_login tools (or "
|
|
56
|
+
"run `argile-mcp login`), or set ARGILE_TOKEN."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def _http(self, timeout: httpx.Timeout | float) -> httpx.AsyncClient:
|
|
60
|
+
return httpx.AsyncClient(timeout=timeout, transport=self.transport)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def _headers(self) -> dict[str, str]:
|
|
64
|
+
return {"Authorization": f"Bearer {self.token}"}
|
|
65
|
+
|
|
66
|
+
async def chat(
|
|
67
|
+
self, message: str, conversation_id: str | None = None
|
|
68
|
+
) -> ChatResult:
|
|
69
|
+
"""Send one message and read the streamed reply to completion."""
|
|
70
|
+
form: dict[str, str] = {"message": message}
|
|
71
|
+
if conversation_id:
|
|
72
|
+
form["conversation_id"] = conversation_id
|
|
73
|
+
|
|
74
|
+
result = ChatResult()
|
|
75
|
+
logger.info(
|
|
76
|
+
"chat turn started: conversation=%s message_chars=%d",
|
|
77
|
+
conversation_id or "new",
|
|
78
|
+
len(message),
|
|
79
|
+
)
|
|
80
|
+
with logfire.span(
|
|
81
|
+
"argile-bot chat",
|
|
82
|
+
conversation_id=conversation_id,
|
|
83
|
+
message_chars=len(message),
|
|
84
|
+
) as span:
|
|
85
|
+
async with self._http(self.chat_timeout) as client:
|
|
86
|
+
async with client.stream(
|
|
87
|
+
"POST",
|
|
88
|
+
f"{self.base_url}/chat/stream",
|
|
89
|
+
data=form,
|
|
90
|
+
headers=self._headers,
|
|
91
|
+
) as response:
|
|
92
|
+
if response.status_code != 200:
|
|
93
|
+
body = (await response.aread()).decode(errors="replace")
|
|
94
|
+
logger.warning(
|
|
95
|
+
"chat turn failed: HTTP %d", response.status_code
|
|
96
|
+
)
|
|
97
|
+
raise ArgileBotError(
|
|
98
|
+
f"HTTP {response.status_code} from bot: {body[:500]}"
|
|
99
|
+
)
|
|
100
|
+
async for line in response.aiter_lines():
|
|
101
|
+
event = parse_sse_frame(line)
|
|
102
|
+
if event is not None:
|
|
103
|
+
fold_event(result, event)
|
|
104
|
+
span.set_attribute("tools_used", result.tools_used)
|
|
105
|
+
span.set_attribute("reply_chars", len(result.reply))
|
|
106
|
+
span.set_attribute("chart_count", result.chart_count)
|
|
107
|
+
logger.info(
|
|
108
|
+
"chat turn done: conversation=%s tools=%s reply_chars=%d charts=%d",
|
|
109
|
+
result.conversation_id,
|
|
110
|
+
result.tools_used,
|
|
111
|
+
len(result.reply),
|
|
112
|
+
result.chart_count,
|
|
113
|
+
)
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
async def _get_json(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
117
|
+
async with self._http(30.0) as client:
|
|
118
|
+
response = await client.get(
|
|
119
|
+
f"{self.base_url}{path}", params=params, headers=self._headers
|
|
120
|
+
)
|
|
121
|
+
if response.status_code != 200:
|
|
122
|
+
logger.warning("GET %s failed: HTTP %d", path, response.status_code)
|
|
123
|
+
raise ArgileBotError(
|
|
124
|
+
f"HTTP {response.status_code} from bot: {response.text[:500]}"
|
|
125
|
+
)
|
|
126
|
+
return response.json()
|
|
127
|
+
|
|
128
|
+
async def list_conversations(self, limit: int = 20) -> list[ConversationSummary]:
|
|
129
|
+
payload = await self._get_json("/conversations", params={"limit": limit})
|
|
130
|
+
return _SUMMARIES.validate_python(payload)
|
|
131
|
+
|
|
132
|
+
async def get_conversation(self, conversation_id: str) -> ConversationDetail:
|
|
133
|
+
payload = await self._get_json(f"/conversations/{conversation_id}")
|
|
134
|
+
return ConversationDetail.model_validate(payload)
|
|
135
|
+
|
|
136
|
+
async def clear_history(self) -> StatusResult:
|
|
137
|
+
async with self._http(30.0) as client:
|
|
138
|
+
response = await client.delete(
|
|
139
|
+
f"{self.base_url}/chat/history", headers=self._headers
|
|
140
|
+
)
|
|
141
|
+
if response.status_code != 200:
|
|
142
|
+
raise ArgileBotError(
|
|
143
|
+
f"HTTP {response.status_code} from bot: {response.text[:500]}"
|
|
144
|
+
)
|
|
145
|
+
return StatusResult.model_validate(response.json())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Settings(BaseSettings):
|
|
7
|
+
"""MCP server configuration, read from the environment (or a .env file).
|
|
8
|
+
|
|
9
|
+
Instantiate at client construction time, not import time, so each server
|
|
10
|
+
process (and each test) picks up the current environment.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# extra="ignore": a .env may also hold the bot's own variables (e.g. when
|
|
14
|
+
# running from the argile-bot checkout).
|
|
15
|
+
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
|
16
|
+
|
|
17
|
+
# Argile API bearer token of the user the bot acts for. Optional when a
|
|
18
|
+
# token has been stored by the login flow (see credentials.py).
|
|
19
|
+
argile_token: str = ""
|
|
20
|
+
|
|
21
|
+
# Where the login flow stores the token it obtains.
|
|
22
|
+
argile_credentials_path: Path = Path.home() / ".argile" / "credentials.json"
|
|
23
|
+
|
|
24
|
+
# Bot base URL.
|
|
25
|
+
argile_bot_url: str = "https://bot.argile.ai"
|
|
26
|
+
|
|
27
|
+
# Reading the whole SSE stream can take a while: the agent may run
|
|
28
|
+
# several CRM tool calls before answering.
|
|
29
|
+
chat_read_timeout: float = 300.0
|
|
30
|
+
|
|
31
|
+
# Stderr log level for the server process. Logfire export is controlled
|
|
32
|
+
# separately by LOGFIRE_TOKEN (unset → no telemetry leaves the machine).
|
|
33
|
+
argile_log_level: str = "INFO"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Local storage for the token obtained by the login flow."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_token(path: Path) -> str | None:
|
|
11
|
+
"""Read the stored token, or None when absent or unreadable."""
|
|
12
|
+
try:
|
|
13
|
+
data = json.loads(path.read_text())
|
|
14
|
+
except (OSError, json.JSONDecodeError):
|
|
15
|
+
return None
|
|
16
|
+
token = data.get("token") if isinstance(data, dict) else None
|
|
17
|
+
return token if isinstance(token, str) and token else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def save_token(path: Path, token: str) -> None:
|
|
21
|
+
"""Store the token, owner-readable only."""
|
|
22
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
path.write_text(json.dumps({"token": token}))
|
|
24
|
+
path.chmod(0o600)
|
|
25
|
+
logger.info("token stored in %s", path)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Pydantic models for the bot's SSE wire contract and MCP tool results."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Annotated, Any, Literal
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
|
8
|
+
|
|
9
|
+
# Enum-ish fields (channel, role, linked-object type) and chart specs are kept
|
|
10
|
+
# open (str / dict) on purpose: the bot owns those vocabularies and may grow
|
|
11
|
+
# them; the MCP passes them through. Everything structural is typed, and the
|
|
12
|
+
# contract tests in argile-bot pin these models to the real routers.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TextEvent(BaseModel):
|
|
16
|
+
type: Literal["text"]
|
|
17
|
+
text: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ToolEvent(BaseModel):
|
|
21
|
+
type: Literal["tool"]
|
|
22
|
+
name: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ChartEvent(BaseModel):
|
|
26
|
+
type: Literal["chart"]
|
|
27
|
+
spec: dict[str, Any] = Field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DoneEvent(BaseModel):
|
|
31
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
32
|
+
|
|
33
|
+
type: Literal["done"]
|
|
34
|
+
conversation_id: str | None = Field(default=None, alias="conversationId")
|
|
35
|
+
active_lead_id: str | None = Field(default=None, alias="activeLeadId")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ErrorEvent(BaseModel):
|
|
39
|
+
type: Literal["error"]
|
|
40
|
+
message: str = "Unknown bot error"
|
|
41
|
+
detail: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
StreamEvent = Annotated[
|
|
45
|
+
TextEvent | ToolEvent | ChartEvent | DoneEvent | ErrorEvent,
|
|
46
|
+
Field(discriminator="type"),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ChatResult(BaseModel):
|
|
51
|
+
"""The bot's reply to one chat turn, accumulated from the SSE stream.
|
|
52
|
+
|
|
53
|
+
`charts` holds the raw chart specs for callers that want them but is
|
|
54
|
+
excluded from dumps — MCP results only carry the count.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
reply: str = ""
|
|
58
|
+
conversation_id: str | None = None
|
|
59
|
+
active_lead_id: str | None = None
|
|
60
|
+
tools_used: list[str] = Field(default_factory=list)
|
|
61
|
+
charts: list[dict[str, Any]] = Field(default_factory=list, exclude=True)
|
|
62
|
+
|
|
63
|
+
@computed_field
|
|
64
|
+
@property
|
|
65
|
+
def chart_count(self) -> int:
|
|
66
|
+
return len(self.charts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --- /conversations payloads (mirrors src/conversations.py in argile-bot) ----
|
|
70
|
+
# extra="allow" so new API fields flow through to the model instead of being
|
|
71
|
+
# silently dropped.
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TextStep(BaseModel):
|
|
75
|
+
type: Literal["text"]
|
|
76
|
+
text: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ToolStep(BaseModel):
|
|
80
|
+
type: Literal["tool"]
|
|
81
|
+
name: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ChartStep(BaseModel):
|
|
85
|
+
type: Literal["chart"]
|
|
86
|
+
spec: dict[str, Any] = Field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
TranscriptStep = Annotated[TextStep | ToolStep | ChartStep, Field(discriminator="type")]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class LinkedObject(BaseModel):
|
|
93
|
+
"""A CRM object linked to a conversation, with a direct-access URL."""
|
|
94
|
+
|
|
95
|
+
model_config = ConfigDict(extra="allow")
|
|
96
|
+
|
|
97
|
+
id: UUID
|
|
98
|
+
type: str
|
|
99
|
+
object_id: str
|
|
100
|
+
label: str | None = None
|
|
101
|
+
url: str | None = None
|
|
102
|
+
created_at: datetime
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Message(BaseModel):
|
|
106
|
+
model_config = ConfigDict(extra="allow")
|
|
107
|
+
|
|
108
|
+
id: UUID
|
|
109
|
+
role: str
|
|
110
|
+
content: str
|
|
111
|
+
steps: list[TranscriptStep] | None = None
|
|
112
|
+
attachment_url: str | None = None
|
|
113
|
+
created_at: datetime
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ConversationBase(BaseModel):
|
|
117
|
+
model_config = ConfigDict(extra="allow")
|
|
118
|
+
|
|
119
|
+
id: UUID
|
|
120
|
+
sender_id: str
|
|
121
|
+
channel: str
|
|
122
|
+
lead_id: str | None = None
|
|
123
|
+
user_email: str | None = None
|
|
124
|
+
created_at: datetime
|
|
125
|
+
last_message_at: datetime
|
|
126
|
+
ended_at: datetime | None = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ConversationSummary(ConversationBase):
|
|
130
|
+
title: str
|
|
131
|
+
message_count: int
|
|
132
|
+
linked_object_count: int
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ConversationDetail(ConversationBase):
|
|
136
|
+
messages: list[Message]
|
|
137
|
+
linked_objects: list[LinkedObject]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class StatusResult(BaseModel):
|
|
141
|
+
status: str
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class DeviceAuthStart(BaseModel):
|
|
145
|
+
"""Handshake material returned by the bot's /auth/mcp/start endpoint.
|
|
146
|
+
|
|
147
|
+
`poll_key` is a one-time secret: hand it back to `complete_login` after
|
|
148
|
+
the user has authorized on `login_url`.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
poll_key: str
|
|
152
|
+
login_url: str
|