ctxd 0.1.0__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.
- ctxd-0.1.0/.gitignore +173 -0
- ctxd-0.1.0/PKG-INFO +68 -0
- ctxd-0.1.0/README.md +57 -0
- ctxd-0.1.0/ctxd/__init__.py +44 -0
- ctxd-0.1.0/ctxd/_metadata.py +6 -0
- ctxd-0.1.0/ctxd/async_client.py +196 -0
- ctxd-0.1.0/ctxd/auth.py +298 -0
- ctxd-0.1.0/ctxd/cli.py +141 -0
- ctxd-0.1.0/ctxd/client.py +97 -0
- ctxd-0.1.0/ctxd/config.py +171 -0
- ctxd-0.1.0/ctxd/exceptions.py +22 -0
- ctxd-0.1.0/ctxd/models.py +121 -0
- ctxd-0.1.0/ctxd/py.typed +0 -0
- ctxd-0.1.0/ctxd/secure_store.py +267 -0
- ctxd-0.1.0/pyproject.toml +25 -0
ctxd-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
secrets/*
|
|
2
|
+
!secrets/.gitkeep
|
|
3
|
+
.docker_env
|
|
4
|
+
.env*
|
|
5
|
+
!.env_example
|
|
6
|
+
app_files/agent/*
|
|
7
|
+
# Byte-compiled / optimized / DLL files
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
*$py.class
|
|
11
|
+
benchmarking/arxiv_*/*
|
|
12
|
+
|
|
13
|
+
scripts/data/*
|
|
14
|
+
# C extensions
|
|
15
|
+
*.so
|
|
16
|
+
# Distribution / packaging
|
|
17
|
+
.Python
|
|
18
|
+
build/
|
|
19
|
+
develop-eggs/
|
|
20
|
+
dist/
|
|
21
|
+
downloads/
|
|
22
|
+
eggs/
|
|
23
|
+
.eggs/
|
|
24
|
+
lib/
|
|
25
|
+
lib64/
|
|
26
|
+
parts/
|
|
27
|
+
sdist/
|
|
28
|
+
var/
|
|
29
|
+
wheels/
|
|
30
|
+
share/python-wheels/
|
|
31
|
+
*.egg-info/
|
|
32
|
+
.installed.cfg
|
|
33
|
+
*.egg
|
|
34
|
+
MANIFEST
|
|
35
|
+
|
|
36
|
+
# PyInstaller
|
|
37
|
+
# Usually these files are written by a python script from a template
|
|
38
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
39
|
+
*.manifest
|
|
40
|
+
*.spec
|
|
41
|
+
|
|
42
|
+
# Installer logs
|
|
43
|
+
pip-log.txt
|
|
44
|
+
pip-delete-this-directory.txt
|
|
45
|
+
|
|
46
|
+
# Unit test / coverage reports
|
|
47
|
+
htmlcov/
|
|
48
|
+
.tox/
|
|
49
|
+
.nox/
|
|
50
|
+
.coverage
|
|
51
|
+
.coverage.*
|
|
52
|
+
.cache
|
|
53
|
+
nosetests.xml
|
|
54
|
+
coverage.xml
|
|
55
|
+
*.cover
|
|
56
|
+
*.py,cover
|
|
57
|
+
.hypothesis/
|
|
58
|
+
.pytest_cache/
|
|
59
|
+
cover/
|
|
60
|
+
|
|
61
|
+
# Translations
|
|
62
|
+
*.mo
|
|
63
|
+
*.pot
|
|
64
|
+
|
|
65
|
+
# Django stuff:
|
|
66
|
+
*.log
|
|
67
|
+
local_settings.py
|
|
68
|
+
db.sqlite3
|
|
69
|
+
db.sqlite3-journal
|
|
70
|
+
|
|
71
|
+
# Flask stuff:
|
|
72
|
+
instance/
|
|
73
|
+
.webassets-cache
|
|
74
|
+
|
|
75
|
+
# Scrapy stuff:
|
|
76
|
+
.scrapy
|
|
77
|
+
|
|
78
|
+
# Sphinx documentation
|
|
79
|
+
docs/_build/
|
|
80
|
+
|
|
81
|
+
# PyBuilder
|
|
82
|
+
.pybuilder/
|
|
83
|
+
target/
|
|
84
|
+
|
|
85
|
+
# Jupyter Notebook
|
|
86
|
+
.ipynb_checkpoints
|
|
87
|
+
|
|
88
|
+
# IPython
|
|
89
|
+
profile_default/
|
|
90
|
+
ipython_config.py
|
|
91
|
+
|
|
92
|
+
# pyenv
|
|
93
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
94
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
95
|
+
# .python-version
|
|
96
|
+
|
|
97
|
+
# pipenv
|
|
98
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
99
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
100
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
101
|
+
# install all needed dependencies.
|
|
102
|
+
#Pipfile.lock
|
|
103
|
+
|
|
104
|
+
# poetry
|
|
105
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
106
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
107
|
+
# commonly ignored for libraries.
|
|
108
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
109
|
+
#poetry.lock
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
#pdm.lock
|
|
114
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
115
|
+
# in version control.
|
|
116
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
117
|
+
.pdm.toml
|
|
118
|
+
|
|
119
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
120
|
+
__pypackages__/
|
|
121
|
+
|
|
122
|
+
# Celery stuff
|
|
123
|
+
celerybeat-schedule
|
|
124
|
+
celerybeat.pid
|
|
125
|
+
|
|
126
|
+
# SageMath parsed files
|
|
127
|
+
*.sage.py
|
|
128
|
+
|
|
129
|
+
# Environments
|
|
130
|
+
.env
|
|
131
|
+
.venv
|
|
132
|
+
env/
|
|
133
|
+
venv/
|
|
134
|
+
ENV/
|
|
135
|
+
env.bak/
|
|
136
|
+
venv.bak/
|
|
137
|
+
|
|
138
|
+
# Spyder project settings
|
|
139
|
+
.spyderproject
|
|
140
|
+
.spyproject
|
|
141
|
+
|
|
142
|
+
# Rope project settings
|
|
143
|
+
.ropeproject
|
|
144
|
+
|
|
145
|
+
# mkdocs documentation
|
|
146
|
+
/site
|
|
147
|
+
|
|
148
|
+
# mypy
|
|
149
|
+
.mypy_cache/
|
|
150
|
+
.dmypy.json
|
|
151
|
+
dmypy.json
|
|
152
|
+
|
|
153
|
+
# Pyre type checker
|
|
154
|
+
.pyre/
|
|
155
|
+
|
|
156
|
+
# pytype static type analyzer
|
|
157
|
+
.pytype/
|
|
158
|
+
|
|
159
|
+
# Cython debug symbols
|
|
160
|
+
cython_debug/
|
|
161
|
+
|
|
162
|
+
# PyCharm
|
|
163
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
164
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
165
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
166
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
167
|
+
.idea/
|
|
168
|
+
.vscode/launch.json
|
|
169
|
+
|
|
170
|
+
# Agent debug artifacts
|
|
171
|
+
debug-artifacts/*
|
|
172
|
+
!debug-artifacts/.gitkeep
|
|
173
|
+
!debug-artifacts/README.md
|
ctxd-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ctxd
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Public Python SDK and CLI for the ctxd platform.
|
|
5
|
+
Requires-Python: <3.13,>=3.11
|
|
6
|
+
Requires-Dist: cryptography>=44.0.0
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Dist: keyring>=25.7.0
|
|
9
|
+
Requires-Dist: pydantic>=2.11.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# ctxd
|
|
13
|
+
|
|
14
|
+
Public Python SDK and CLI for the `ctxd` platform.
|
|
15
|
+
|
|
16
|
+
Install:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install ctxd
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The SDK exposes sync and async clients:
|
|
23
|
+
|
|
24
|
+
- `Client`
|
|
25
|
+
- `AsyncClient`
|
|
26
|
+
|
|
27
|
+
Authentication:
|
|
28
|
+
|
|
29
|
+
1. Run `ctxd login` with the CLI to store OAuth credentials in a secure local store
|
|
30
|
+
2. The CLI starts a device-style login session through the backend auth API
|
|
31
|
+
3. The browser flow continues on `https://app.ctxd.dev/cli-login`, so login can finish on a different machine than the terminal session
|
|
32
|
+
4. The backend dynamically registers the CLI session with the MCP authorization server and completes the OAuth callback server-side
|
|
33
|
+
5. The SDK keeps only non-secret metadata in `~/.ctxd/config.json`
|
|
34
|
+
6. The SDK reads the stored access token and refreshes it automatically when needed
|
|
35
|
+
7. Tokens are loaded from the OS keychain when available, otherwise from an encrypted local credential store unlocked with a passphrase entered interactively or provided through `CTXD_PASSPHRASE`
|
|
36
|
+
|
|
37
|
+
Base URL resolution order:
|
|
38
|
+
|
|
39
|
+
1. `base_url=` passed to the client
|
|
40
|
+
2. `CTXD_BASE_URL`
|
|
41
|
+
3. `~/.ctxd/config.json`
|
|
42
|
+
4. `https://mcp.ctxd.dev`
|
|
43
|
+
|
|
44
|
+
Auth API URL resolution order:
|
|
45
|
+
|
|
46
|
+
1. `CTXD_AUTH_API_URL`
|
|
47
|
+
2. `https://api.ctxd.dev`
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from ctxd import Client
|
|
53
|
+
|
|
54
|
+
client = Client()
|
|
55
|
+
|
|
56
|
+
results = client.search("text:deployment application:slack")
|
|
57
|
+
profile = client.get_profile()
|
|
58
|
+
document = client.fetch_document("doc-123")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Async example:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from ctxd import AsyncClient
|
|
65
|
+
|
|
66
|
+
async with AsyncClient() as client:
|
|
67
|
+
results = await client.search("text:deployment")
|
|
68
|
+
```
|
ctxd-0.1.0/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# ctxd
|
|
2
|
+
|
|
3
|
+
Public Python SDK and CLI for the `ctxd` platform.
|
|
4
|
+
|
|
5
|
+
Install:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install ctxd
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The SDK exposes sync and async clients:
|
|
12
|
+
|
|
13
|
+
- `Client`
|
|
14
|
+
- `AsyncClient`
|
|
15
|
+
|
|
16
|
+
Authentication:
|
|
17
|
+
|
|
18
|
+
1. Run `ctxd login` with the CLI to store OAuth credentials in a secure local store
|
|
19
|
+
2. The CLI starts a device-style login session through the backend auth API
|
|
20
|
+
3. The browser flow continues on `https://app.ctxd.dev/cli-login`, so login can finish on a different machine than the terminal session
|
|
21
|
+
4. The backend dynamically registers the CLI session with the MCP authorization server and completes the OAuth callback server-side
|
|
22
|
+
5. The SDK keeps only non-secret metadata in `~/.ctxd/config.json`
|
|
23
|
+
6. The SDK reads the stored access token and refreshes it automatically when needed
|
|
24
|
+
7. Tokens are loaded from the OS keychain when available, otherwise from an encrypted local credential store unlocked with a passphrase entered interactively or provided through `CTXD_PASSPHRASE`
|
|
25
|
+
|
|
26
|
+
Base URL resolution order:
|
|
27
|
+
|
|
28
|
+
1. `base_url=` passed to the client
|
|
29
|
+
2. `CTXD_BASE_URL`
|
|
30
|
+
3. `~/.ctxd/config.json`
|
|
31
|
+
4. `https://mcp.ctxd.dev`
|
|
32
|
+
|
|
33
|
+
Auth API URL resolution order:
|
|
34
|
+
|
|
35
|
+
1. `CTXD_AUTH_API_URL`
|
|
36
|
+
2. `https://api.ctxd.dev`
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from ctxd import Client
|
|
42
|
+
|
|
43
|
+
client = Client()
|
|
44
|
+
|
|
45
|
+
results = client.search("text:deployment application:slack")
|
|
46
|
+
profile = client.get_profile()
|
|
47
|
+
document = client.fetch_document("doc-123")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Async example:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from ctxd import AsyncClient
|
|
54
|
+
|
|
55
|
+
async with AsyncClient() as client:
|
|
56
|
+
results = await client.search("text:deployment")
|
|
57
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Public Python SDK for ctxd."""
|
|
2
|
+
|
|
3
|
+
from ctxd._metadata import SDK_NAME, SDK_VERSION, get_user_agent
|
|
4
|
+
from ctxd.async_client import AsyncClient
|
|
5
|
+
from ctxd.auth import ensure_valid_credentials, login_with_browser, logout
|
|
6
|
+
from ctxd.client import Client, CtxdClient
|
|
7
|
+
from ctxd.config import (
|
|
8
|
+
DEFAULT_BASE_URL,
|
|
9
|
+
clear_credentials,
|
|
10
|
+
get_config_path,
|
|
11
|
+
load_config,
|
|
12
|
+
load_credentials,
|
|
13
|
+
save_config,
|
|
14
|
+
save_credentials,
|
|
15
|
+
)
|
|
16
|
+
from ctxd.exceptions import CtxdAuthError, CtxdError, CtxdProtocolError
|
|
17
|
+
from ctxd.models import CredentialStore, DocumentResult, ProfileResult, SearchResult
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AsyncClient",
|
|
21
|
+
"Client",
|
|
22
|
+
"CredentialStore",
|
|
23
|
+
"CtxdClient",
|
|
24
|
+
"CtxdAuthError",
|
|
25
|
+
"CtxdError",
|
|
26
|
+
"CtxdProtocolError",
|
|
27
|
+
"DEFAULT_BASE_URL",
|
|
28
|
+
"DocumentResult",
|
|
29
|
+
"ProfileResult",
|
|
30
|
+
"SDK_NAME",
|
|
31
|
+
"SearchResult",
|
|
32
|
+
"__version__",
|
|
33
|
+
"clear_credentials",
|
|
34
|
+
"ensure_valid_credentials",
|
|
35
|
+
"get_config_path",
|
|
36
|
+
"get_user_agent",
|
|
37
|
+
"load_credentials",
|
|
38
|
+
"login_with_browser",
|
|
39
|
+
"load_config",
|
|
40
|
+
"logout",
|
|
41
|
+
"save_config",
|
|
42
|
+
"save_credentials",
|
|
43
|
+
]
|
|
44
|
+
__version__ = SDK_VERSION
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ctxd._metadata import get_user_agent
|
|
8
|
+
from ctxd.auth import ensure_valid_credentials, login_with_browser, logout
|
|
9
|
+
from ctxd.config import resolve_base_url
|
|
10
|
+
from ctxd.exceptions import CtxdAuthError, CtxdError, CtxdProtocolError
|
|
11
|
+
from ctxd.models import DocumentResult, ProfileResult, SearchResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsyncClient:
|
|
15
|
+
"""Async client for the public ctxd MCP endpoint."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
access_token: str | None = None,
|
|
21
|
+
base_url: str | None = None,
|
|
22
|
+
client_id: str | None = None,
|
|
23
|
+
timeout: float = 30.0,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._base_url = self._normalize_base_url(resolve_base_url(base_url))
|
|
26
|
+
self._access_token = access_token.strip() if access_token else None
|
|
27
|
+
self._client_id = client_id.strip() if client_id else None
|
|
28
|
+
self._timeout = timeout
|
|
29
|
+
self._client: httpx.AsyncClient | None = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def base_url(self) -> str:
|
|
33
|
+
return self._base_url
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def client_id(self) -> str | None:
|
|
37
|
+
return self._client_id
|
|
38
|
+
|
|
39
|
+
async def __aenter__(self) -> "AsyncClient":
|
|
40
|
+
self._client = httpx.AsyncClient(timeout=self._timeout)
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
44
|
+
if self._client is not None:
|
|
45
|
+
await self._client.aclose()
|
|
46
|
+
self._client = None
|
|
47
|
+
|
|
48
|
+
async def search(self, query: str) -> SearchResult:
|
|
49
|
+
payload = await self.call_tool("search", {"query": query})
|
|
50
|
+
return SearchResult.model_validate(payload)
|
|
51
|
+
|
|
52
|
+
async def fetch_document(self, document_uid: str) -> DocumentResult:
|
|
53
|
+
payload = await self.call_tool("fetch_document", {"document_uid": document_uid})
|
|
54
|
+
return DocumentResult.model_validate(payload)
|
|
55
|
+
|
|
56
|
+
async def get_profile(self) -> ProfileResult:
|
|
57
|
+
payload = await self.call_tool("get_profile", {})
|
|
58
|
+
return ProfileResult.model_validate(payload)
|
|
59
|
+
|
|
60
|
+
async def login(self, *, open_browser: bool = True, timeout_seconds: float = 300.0):
|
|
61
|
+
return await _run_sync(
|
|
62
|
+
login_with_browser,
|
|
63
|
+
base_url=self._base_url,
|
|
64
|
+
timeout_seconds=timeout_seconds,
|
|
65
|
+
open_browser=open_browser,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def logout(self, *, keep_base_url: bool = True) -> None:
|
|
69
|
+
logout(keep_base_url=keep_base_url)
|
|
70
|
+
|
|
71
|
+
async def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
72
|
+
request_body = {
|
|
73
|
+
"jsonrpc": "2.0",
|
|
74
|
+
"method": "tools/call",
|
|
75
|
+
"params": {
|
|
76
|
+
"name": name,
|
|
77
|
+
"arguments": arguments,
|
|
78
|
+
},
|
|
79
|
+
"id": 1,
|
|
80
|
+
}
|
|
81
|
+
token = await self._resolve_access_token()
|
|
82
|
+
headers = {
|
|
83
|
+
"Authorization": f"Bearer {token}",
|
|
84
|
+
"Accept": "application/json, text/event-stream",
|
|
85
|
+
"User-Agent": get_user_agent(),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if self._client is not None:
|
|
89
|
+
response = await self._client.post(
|
|
90
|
+
self._base_url,
|
|
91
|
+
headers=headers,
|
|
92
|
+
json=request_body,
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
96
|
+
response = await client.post(
|
|
97
|
+
self._base_url,
|
|
98
|
+
headers=headers,
|
|
99
|
+
json=request_body,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return self._parse_response(response)
|
|
103
|
+
|
|
104
|
+
async def _resolve_access_token(self) -> str:
|
|
105
|
+
if self._access_token:
|
|
106
|
+
return self._access_token
|
|
107
|
+
|
|
108
|
+
credentials = await _run_sync(
|
|
109
|
+
ensure_valid_credentials,
|
|
110
|
+
base_url=self._base_url,
|
|
111
|
+
client_id=self._client_id,
|
|
112
|
+
)
|
|
113
|
+
if not credentials.access_token:
|
|
114
|
+
raise CtxdAuthError("Missing access token. Run `ctxd login` first.")
|
|
115
|
+
return credentials.access_token
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _normalize_base_url(base_url: str) -> str:
|
|
119
|
+
normalized = base_url.rstrip("/")
|
|
120
|
+
if normalized.endswith("/sse"):
|
|
121
|
+
normalized = normalized[: -len("/sse")]
|
|
122
|
+
if not normalized.endswith("/mcp"):
|
|
123
|
+
normalized = f"{normalized}/mcp"
|
|
124
|
+
return normalized
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _parse_response(response: httpx.Response) -> dict[str, Any]:
|
|
128
|
+
if response.status_code >= 400:
|
|
129
|
+
message = f"ctxd MCP request failed with status {response.status_code}"
|
|
130
|
+
try:
|
|
131
|
+
error_payload = response.json()
|
|
132
|
+
except ValueError:
|
|
133
|
+
error_payload = response.text
|
|
134
|
+
raise CtxdError(
|
|
135
|
+
message, status_code=response.status_code, payload=error_payload
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
content_type = response.headers.get("content-type", "")
|
|
139
|
+
if "text/event-stream" in content_type:
|
|
140
|
+
return AsyncClient._parse_sse_payload(response.text)
|
|
141
|
+
if "application/json" in content_type:
|
|
142
|
+
return AsyncClient._parse_json_payload(response.json())
|
|
143
|
+
|
|
144
|
+
if response.text.startswith("event:") or response.text.startswith("data:"):
|
|
145
|
+
return AsyncClient._parse_sse_payload(response.text)
|
|
146
|
+
|
|
147
|
+
raise CtxdProtocolError(
|
|
148
|
+
f"Unsupported MCP response content type: {content_type or 'unknown'}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _parse_sse_payload(raw_text: str) -> dict[str, Any]:
|
|
153
|
+
data_line = next(
|
|
154
|
+
(line for line in raw_text.splitlines() if line.startswith("data: ")),
|
|
155
|
+
None,
|
|
156
|
+
)
|
|
157
|
+
if data_line is None:
|
|
158
|
+
raise CtxdProtocolError("MCP SSE response did not contain a data line")
|
|
159
|
+
|
|
160
|
+
body = json.loads(data_line[len("data: ") :])
|
|
161
|
+
return AsyncClient._parse_json_payload(body)
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _parse_json_payload(body: dict[str, Any]) -> dict[str, Any]:
|
|
165
|
+
if "error" in body:
|
|
166
|
+
raise CtxdError("MCP JSON-RPC error", payload=body["error"])
|
|
167
|
+
|
|
168
|
+
result = body.get("result")
|
|
169
|
+
if not isinstance(result, dict):
|
|
170
|
+
raise CtxdProtocolError("MCP response did not include a result object")
|
|
171
|
+
|
|
172
|
+
content = result.get("content")
|
|
173
|
+
if not isinstance(content, list) or not content:
|
|
174
|
+
raise CtxdProtocolError("MCP result content was missing or empty")
|
|
175
|
+
|
|
176
|
+
first_item = content[0]
|
|
177
|
+
if first_item.get("type") != "text":
|
|
178
|
+
raise CtxdProtocolError("MCP result content item was not text")
|
|
179
|
+
|
|
180
|
+
text = first_item.get("text")
|
|
181
|
+
if not isinstance(text, str):
|
|
182
|
+
raise CtxdProtocolError("MCP result text payload was not a string")
|
|
183
|
+
|
|
184
|
+
if result.get("isError"):
|
|
185
|
+
raise CtxdError(text, payload=result)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
return json.loads(text)
|
|
189
|
+
except json.JSONDecodeError as exc:
|
|
190
|
+
raise CtxdProtocolError(
|
|
191
|
+
"MCP result text payload was not valid JSON"
|
|
192
|
+
) from exc
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def _run_sync(func, /, *args, **kwargs):
|
|
196
|
+
return await asyncio.to_thread(func, *args, **kwargs)
|