ciridae 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.
- ciridae-0.1.0/.gitignore +1 -0
- ciridae-0.1.0/PKG-INFO +46 -0
- ciridae-0.1.0/README.md +35 -0
- ciridae-0.1.0/_sources/ciridae/auth/__init__.py +23 -0
- ciridae-0.1.0/_sources/ciridae/auth/access.py +23 -0
- ciridae-0.1.0/_sources/ciridae/auth/api_error.py +22 -0
- ciridae-0.1.0/_sources/ciridae/auth/client.py +76 -0
- ciridae-0.1.0/_sources/ciridae/auth/py.typed +1 -0
- ciridae-0.1.0/_sources/ciridae/mcp/__init__.py +45 -0
- ciridae-0.1.0/_sources/ciridae/mcp/access.py +26 -0
- ciridae-0.1.0/_sources/ciridae/mcp/api_error.py +25 -0
- ciridae-0.1.0/_sources/ciridae/mcp/client.py +211 -0
- ciridae-0.1.0/_sources/ciridae/mcp/connect_link.py +36 -0
- ciridae-0.1.0/_sources/ciridae/mcp/endpoint.py +214 -0
- ciridae-0.1.0/_sources/ciridae/mcp/project_tool_inventory.py +67 -0
- ciridae-0.1.0/_sources/ciridae/mcp/py.typed +1 -0
- ciridae-0.1.0/_sources/ciridae/mcp/tool_operation.py +18 -0
- ciridae-0.1.0/_sources/ciridae/project/__init__.py +94 -0
- ciridae-0.1.0/_sources/ciridae/project/cli.py +33 -0
- ciridae-0.1.0/_sources/ciridae/project/identity.py +13 -0
- ciridae-0.1.0/_sources/ciridae/project/models.py +560 -0
- ciridae-0.1.0/_sources/ciridae/project/py.typed +1 -0
- ciridae-0.1.0/_sources/ciridae/project/validation.py +42 -0
- ciridae-0.1.0/hatch_build.py +83 -0
- ciridae-0.1.0/public-package-files.txt +22 -0
- ciridae-0.1.0/pyproject.toml +35 -0
- ciridae-0.1.0/src/ciridae/__init__.py +1 -0
- ciridae-0.1.0/src/ciridae/py.typed +1 -0
- ciridae-0.1.0/verify_public_wheel.py +30 -0
ciridae-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/_editable/
|
ciridae-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ciridae
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Public Python clients, models, and project utilities for Ciridae
|
|
5
|
+
Project-URL: Repository, https://github.com/ciridae-ai/ciridae
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Dist: pydantic>=2.11.7
|
|
9
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Ciridae
|
|
13
|
+
|
|
14
|
+
Public Python clients, models, and project utilities consumed by Ciridae
|
|
15
|
+
services, standalone projects, and templates.
|
|
16
|
+
|
|
17
|
+
The domain libraries remain the source owners in `libs/auth-client`,
|
|
18
|
+
`libs/mcp-access`, and `libs/project-contract`. This directory only assembles
|
|
19
|
+
their `ciridae.*` namespaces into one public distribution.
|
|
20
|
+
|
|
21
|
+
`public-package-files.txt` is the reviewed artifact boundary. Adding any source
|
|
22
|
+
file under an owned namespace fails the build until that file is explicitly
|
|
23
|
+
added to the manifest.
|
|
24
|
+
|
|
25
|
+
The PyPI distribution is `ciridae`. Import from the relevant domain namespace:
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from ciridae.auth import AuthServiceAccessResponse, AuthServiceClient
|
|
29
|
+
from ciridae.mcp import CiridaeMcpClient, ProjectToolInventoryResponse
|
|
30
|
+
from ciridae.project import ProjectDefinition
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Validate a `project.yaml` file with the same packaged contract:
|
|
34
|
+
|
|
35
|
+
```console
|
|
36
|
+
ciridae validate-project project.yaml
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Releasing
|
|
40
|
+
|
|
41
|
+
1. Update `version` in `pyproject.toml`.
|
|
42
|
+
2. Merge the change to `main`.
|
|
43
|
+
3. Push the matching tag, for example `ciridae-v0.1.0`.
|
|
44
|
+
|
|
45
|
+
The tag publishes through PyPI Trusted Publishing. Releases are immutable;
|
|
46
|
+
change the version rather than replacing an existing artifact.
|
ciridae-0.1.0/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Ciridae
|
|
2
|
+
|
|
3
|
+
Public Python clients, models, and project utilities consumed by Ciridae
|
|
4
|
+
services, standalone projects, and templates.
|
|
5
|
+
|
|
6
|
+
The domain libraries remain the source owners in `libs/auth-client`,
|
|
7
|
+
`libs/mcp-access`, and `libs/project-contract`. This directory only assembles
|
|
8
|
+
their `ciridae.*` namespaces into one public distribution.
|
|
9
|
+
|
|
10
|
+
`public-package-files.txt` is the reviewed artifact boundary. Adding any source
|
|
11
|
+
file under an owned namespace fails the build until that file is explicitly
|
|
12
|
+
added to the manifest.
|
|
13
|
+
|
|
14
|
+
The PyPI distribution is `ciridae`. Import from the relevant domain namespace:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from ciridae.auth import AuthServiceAccessResponse, AuthServiceClient
|
|
18
|
+
from ciridae.mcp import CiridaeMcpClient, ProjectToolInventoryResponse
|
|
19
|
+
from ciridae.project import ProjectDefinition
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Validate a `project.yaml` file with the same packaged contract:
|
|
23
|
+
|
|
24
|
+
```console
|
|
25
|
+
ciridae validate-project project.yaml
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Releasing
|
|
29
|
+
|
|
30
|
+
1. Update `version` in `pyproject.toml`.
|
|
31
|
+
2. Merge the change to `main`.
|
|
32
|
+
3. Push the matching tag, for example `ciridae-v0.1.0`.
|
|
33
|
+
|
|
34
|
+
The tag publishes through PyPI Trusted Publishing. Releases are immutable;
|
|
35
|
+
change the version rather than replacing an existing artifact.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Client and public models for the Ciridae Auth Service."""
|
|
2
|
+
|
|
3
|
+
from ciridae.auth.access import (
|
|
4
|
+
AuthServiceAccessResponse,
|
|
5
|
+
FirebaseUid,
|
|
6
|
+
FirebaseUidValue,
|
|
7
|
+
)
|
|
8
|
+
from ciridae.auth.api_error import (
|
|
9
|
+
AuthServiceApiError,
|
|
10
|
+
AuthServiceRejectedError,
|
|
11
|
+
AuthServiceUnavailableError,
|
|
12
|
+
)
|
|
13
|
+
from ciridae.auth.client import AuthServiceClient
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AuthServiceAccessResponse",
|
|
17
|
+
"AuthServiceApiError",
|
|
18
|
+
"AuthServiceClient",
|
|
19
|
+
"AuthServiceRejectedError",
|
|
20
|
+
"AuthServiceUnavailableError",
|
|
21
|
+
"FirebaseUid",
|
|
22
|
+
"FirebaseUidValue",
|
|
23
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Auth Service access-check response consumed by project backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, ClassVar, NewType
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, StringConstraints
|
|
8
|
+
|
|
9
|
+
FirebaseUid = NewType("FirebaseUid", str)
|
|
10
|
+
FirebaseUidValue = Annotated[
|
|
11
|
+
FirebaseUid,
|
|
12
|
+
StringConstraints(min_length=1, strip_whitespace=True),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthServiceAccessResponse(BaseModel):
|
|
17
|
+
"""Result of verifying one bearer against a project's Auth realm."""
|
|
18
|
+
|
|
19
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", frozen=True)
|
|
20
|
+
|
|
21
|
+
has_access: bool
|
|
22
|
+
user_id: FirebaseUidValue | None
|
|
23
|
+
email: str | None
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Auth Service client error."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AuthServiceApiError(RuntimeError):
|
|
5
|
+
"""Raised when an Auth Service request does not complete successfully."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, *, status_code: int | None = None) -> None:
|
|
8
|
+
"""Record whether the Auth Service returned an HTTP status.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
status_code: The response status, or ``None`` for a transport failure.
|
|
12
|
+
"""
|
|
13
|
+
super().__init__("Auth Service request failed")
|
|
14
|
+
self.status_code: int | None = status_code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthServiceRejectedError(AuthServiceApiError):
|
|
18
|
+
"""Raised when the Auth Service definitively rejects a request."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthServiceUnavailableError(AuthServiceApiError):
|
|
22
|
+
"""Raised when no trustworthy Auth Service response is available."""
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""HTTP client for the Ciridae Auth Service."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ciridae.auth.api_error import (
|
|
8
|
+
AuthServiceRejectedError,
|
|
9
|
+
AuthServiceUnavailableError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthServiceClient(httpx.AsyncClient):
|
|
14
|
+
"""Async client bound to one Auth Service endpoint and service API key."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
api_url: str,
|
|
20
|
+
api_key: str,
|
|
21
|
+
timeout: float | httpx.Timeout,
|
|
22
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Configure the Auth Service URL, bearer, and transport policy.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
api_url: Auth Service API root.
|
|
28
|
+
api_key: Project-scoped service API key.
|
|
29
|
+
timeout: HTTPX timeout policy for Auth Service requests.
|
|
30
|
+
transport: Optional transport override for boundary tests.
|
|
31
|
+
"""
|
|
32
|
+
super().__init__(
|
|
33
|
+
base_url=api_url,
|
|
34
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
35
|
+
timeout=timeout,
|
|
36
|
+
follow_redirects=False,
|
|
37
|
+
trust_env=False,
|
|
38
|
+
transport=transport,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
async def request_success(
|
|
42
|
+
self,
|
|
43
|
+
method: Literal["GET", "POST", "DELETE"],
|
|
44
|
+
path: str,
|
|
45
|
+
*,
|
|
46
|
+
params: dict[str, str | int] | None = None,
|
|
47
|
+
json: dict[str, object] | None = None,
|
|
48
|
+
) -> httpx.Response:
|
|
49
|
+
"""Make one request and return only a definite 2xx response.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
method: HTTP method.
|
|
53
|
+
path: Path relative to the configured Auth Service API root.
|
|
54
|
+
params: Optional query parameters.
|
|
55
|
+
json: Optional JSON request body.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The successful Auth Service response.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
AuthServiceApiError: If transport fails or the service returns a
|
|
62
|
+
non-2xx status.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
response = await self.request(method, path, params=params, json=json)
|
|
66
|
+
except httpx.RequestError:
|
|
67
|
+
# Leave the exception boundary before raising: RequestError retains
|
|
68
|
+
# its request, whose headers carry the bearer.
|
|
69
|
+
response = None
|
|
70
|
+
if response is None:
|
|
71
|
+
raise AuthServiceUnavailableError
|
|
72
|
+
if response.is_success:
|
|
73
|
+
return response
|
|
74
|
+
if response.is_server_error:
|
|
75
|
+
raise AuthServiceUnavailableError(status_code=response.status_code)
|
|
76
|
+
raise AuthServiceRejectedError(status_code=response.status_code)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Client and public models for Ciridae MCP project access."""
|
|
2
|
+
|
|
3
|
+
from ciridae.mcp.access import (
|
|
4
|
+
DEFAULT_MCP_ACCESS_MODE,
|
|
5
|
+
ConnectCode,
|
|
6
|
+
ConnectUserId,
|
|
7
|
+
McpAccessCategoryMode,
|
|
8
|
+
McpAccessMode,
|
|
9
|
+
)
|
|
10
|
+
from ciridae.mcp.api_error import CiridaeMcpApiError
|
|
11
|
+
from ciridae.mcp.client import (
|
|
12
|
+
CiridaeMcpClient,
|
|
13
|
+
fetch_project_tool_inventory,
|
|
14
|
+
mint_connect_link,
|
|
15
|
+
)
|
|
16
|
+
from ciridae.mcp.connect_link import ConnectLink
|
|
17
|
+
from ciridae.mcp.project_tool_inventory import (
|
|
18
|
+
AvailableProjectToolInventoryResponse,
|
|
19
|
+
ProjectToolInventoryItemResponse,
|
|
20
|
+
ProjectToolInventoryResponse,
|
|
21
|
+
UnavailableProjectToolInventoryResponse,
|
|
22
|
+
)
|
|
23
|
+
from ciridae.mcp.tool_operation import HttpMethod, ToolOperation
|
|
24
|
+
from ciridae.project import ProjectId, ProjectIdValue
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"DEFAULT_MCP_ACCESS_MODE",
|
|
28
|
+
"AvailableProjectToolInventoryResponse",
|
|
29
|
+
"CiridaeMcpApiError",
|
|
30
|
+
"CiridaeMcpClient",
|
|
31
|
+
"ConnectCode",
|
|
32
|
+
"ConnectLink",
|
|
33
|
+
"ConnectUserId",
|
|
34
|
+
"HttpMethod",
|
|
35
|
+
"McpAccessCategoryMode",
|
|
36
|
+
"McpAccessMode",
|
|
37
|
+
"ProjectId",
|
|
38
|
+
"ProjectIdValue",
|
|
39
|
+
"ProjectToolInventoryItemResponse",
|
|
40
|
+
"ProjectToolInventoryResponse",
|
|
41
|
+
"ToolOperation",
|
|
42
|
+
"UnavailableProjectToolInventoryResponse",
|
|
43
|
+
"fetch_project_tool_inventory",
|
|
44
|
+
"mint_connect_link",
|
|
45
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Access vocabulary shared with the Ciridae MCP gateway."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
ConnectCode = Annotated[
|
|
10
|
+
str,
|
|
11
|
+
Field(
|
|
12
|
+
min_length=1,
|
|
13
|
+
description="Single-use code pasted at the gateway's consent screen.",
|
|
14
|
+
),
|
|
15
|
+
]
|
|
16
|
+
ConnectUserId = Annotated[
|
|
17
|
+
str,
|
|
18
|
+
Field(
|
|
19
|
+
min_length=1,
|
|
20
|
+
max_length=128,
|
|
21
|
+
description="User ID the minted connect link will act as.",
|
|
22
|
+
),
|
|
23
|
+
]
|
|
24
|
+
McpAccessMode = Literal["read_only", "all", "selected"]
|
|
25
|
+
McpAccessCategoryMode = Literal["read_only", "all"]
|
|
26
|
+
DEFAULT_MCP_ACCESS_MODE: McpAccessMode = "read_only"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Custom exception type raised by Ciridae MCP gateway calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CiridaeMcpApiError(RuntimeError):
|
|
7
|
+
"""Raised when a Ciridae MCP gateway call cannot complete successfully.
|
|
8
|
+
|
|
9
|
+
Covers a gateway that never answered as well as one that answered wrongly,
|
|
10
|
+
because every caller handles the two the same way.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
status_code: The status the gateway returned, or `None` when the
|
|
14
|
+
failure happened before a response arrived.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
|
18
|
+
"""Build a `CiridaeMcpApiError`.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
message: Human-readable description of the failure.
|
|
22
|
+
status_code: The status the gateway returned, if it answered.
|
|
23
|
+
"""
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.status_code: int | None = status_code
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""HTTP client for the Ciridae MCP gateway."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from http import HTTPStatus
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from ciridae.mcp.access import ConnectUserId, McpAccessMode
|
|
10
|
+
from ciridae.mcp.api_error import CiridaeMcpApiError
|
|
11
|
+
from ciridae.mcp.connect_link import ConnectLink
|
|
12
|
+
from ciridae.mcp.endpoint import CiridaeMcpApiRoot
|
|
13
|
+
from ciridae.mcp.project_tool_inventory import ProjectToolInventoryResponse
|
|
14
|
+
from ciridae.mcp.tool_operation import ToolOperation
|
|
15
|
+
from ciridae.project import ProjectId
|
|
16
|
+
from pydantic import SecretStr, TypeAdapter, ValidationError
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Bound both local gateway work and downstream project-catalog reads.
|
|
21
|
+
_DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
22
|
+
_PROJECT_TOOL_INVENTORY_ADAPTER: TypeAdapter[ProjectToolInventoryResponse] = (
|
|
23
|
+
TypeAdapter(ProjectToolInventoryResponse)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CiridaeMcpClient(httpx.AsyncClient):
|
|
28
|
+
"""Async client bound to one Ciridae MCP gateway and management key."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
api_root: CiridaeMcpApiRoot,
|
|
34
|
+
management_key: SecretStr,
|
|
35
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Configure the fixed gateway URL, bearer, and transport policy.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
api_root: The gateway API root; `to_api_root` derives this from a
|
|
41
|
+
validated `CIRIDAE_MCP_ENDPOINT`.
|
|
42
|
+
management_key: The project endpoint-scoped management key.
|
|
43
|
+
transport: Optional transport override for boundary tests.
|
|
44
|
+
"""
|
|
45
|
+
super().__init__(
|
|
46
|
+
base_url=api_root,
|
|
47
|
+
timeout=_DEFAULT_TIMEOUT_SECONDS,
|
|
48
|
+
headers={"Authorization": f"Bearer {management_key.get_secret_value()}"},
|
|
49
|
+
# Both are about where the bearer can end up. httpx already
|
|
50
|
+
# defaults to not following redirects; stated here because a
|
|
51
|
+
# redirect the gateway did not author would carry the header to
|
|
52
|
+
# whatever host it named. `trust_env=False` is the one that
|
|
53
|
+
# changes behaviour: it stops HTTPS_PROXY from putting a third
|
|
54
|
+
# party on the path of every mint.
|
|
55
|
+
follow_redirects=False,
|
|
56
|
+
trust_env=False,
|
|
57
|
+
transport=transport,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def fetch_project_tool_inventory(
|
|
62
|
+
client: CiridaeMcpClient,
|
|
63
|
+
*,
|
|
64
|
+
expected_project_id: ProjectId,
|
|
65
|
+
) -> ProjectToolInventoryResponse:
|
|
66
|
+
"""Fetch the compiled inventory authorized by a project's management key.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
client: The client bound to this deployment's gateway and bearer.
|
|
70
|
+
expected_project_id: The canonical project the caller expects the key
|
|
71
|
+
to select.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The gateway's available or explicitly unavailable inventory.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
CiridaeMcpApiError: If the gateway cannot be reached, rejects the
|
|
78
|
+
request, answers unreadably, or selects a different project.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
response = await client.get("/v1/tools")
|
|
82
|
+
except httpx.RequestError:
|
|
83
|
+
# `from None`: `RequestError.request.headers` carries the bearer.
|
|
84
|
+
msg = "Ciridae MCP could not be reached"
|
|
85
|
+
raise CiridaeMcpApiError(msg) from None
|
|
86
|
+
if response.status_code != HTTPStatus.OK:
|
|
87
|
+
# No body: a rejection can echo the request.
|
|
88
|
+
logger.warning(
|
|
89
|
+
"Ciridae MCP tool inventory failed with status %s", response.status_code
|
|
90
|
+
)
|
|
91
|
+
msg = f"Ciridae MCP refused the tool inventory (status={response.status_code})"
|
|
92
|
+
raise CiridaeMcpApiError(msg, status_code=response.status_code)
|
|
93
|
+
try:
|
|
94
|
+
# The gateway owns this response and may add metadata before every
|
|
95
|
+
# consumer deploys. Keep the producer model closed while making this
|
|
96
|
+
# cross-service read boundary forward-compatible with additive fields.
|
|
97
|
+
inventory = _PROJECT_TOOL_INVENTORY_ADAPTER.validate_json(
|
|
98
|
+
response.content,
|
|
99
|
+
extra="ignore",
|
|
100
|
+
)
|
|
101
|
+
except ValidationError:
|
|
102
|
+
# The validation error may quote the upstream body.
|
|
103
|
+
msg = "Ciridae MCP returned a tool inventory that could not be read"
|
|
104
|
+
raise CiridaeMcpApiError(msg) from None
|
|
105
|
+
if inventory.project_id != expected_project_id:
|
|
106
|
+
msg = "Ciridae MCP returned a tool inventory for a different project"
|
|
107
|
+
raise CiridaeMcpApiError(msg)
|
|
108
|
+
return inventory
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def mint_connect_link(
|
|
112
|
+
client: CiridaeMcpClient,
|
|
113
|
+
*,
|
|
114
|
+
user_id: ConnectUserId,
|
|
115
|
+
access: McpAccessMode,
|
|
116
|
+
tools: tuple[ToolOperation, ...] | None = None,
|
|
117
|
+
) -> ConnectLink:
|
|
118
|
+
"""Mint a connect link the named user can redeem in an MCP client.
|
|
119
|
+
|
|
120
|
+
The management key is the whole authorization; the gateway does not
|
|
121
|
+
re-check membership, so passing an authenticated UID is a caller
|
|
122
|
+
obligation.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
client: The client bound to this deployment's gateway and bearer.
|
|
126
|
+
user_id: User id of the member the link will act as.
|
|
127
|
+
access: The access the redeemed connection is granted.
|
|
128
|
+
tools: The exact operations granted when ``access`` is ``selected``;
|
|
129
|
+
the gateway refuses any other pairing.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The minted link. Its URL is visible only in this response.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
CiridaeMcpApiError: If the gateway cannot be reached, rejects the
|
|
136
|
+
request, or answers with an unreadable link or one bound to a
|
|
137
|
+
different user or access mode.
|
|
138
|
+
"""
|
|
139
|
+
payload: dict[str, object] = {"user_id": user_id, "access": access}
|
|
140
|
+
if tools is not None:
|
|
141
|
+
payload["tools"] = [
|
|
142
|
+
{
|
|
143
|
+
"method": operation.method,
|
|
144
|
+
"path_template": operation.path_template,
|
|
145
|
+
}
|
|
146
|
+
for operation in tools
|
|
147
|
+
]
|
|
148
|
+
try:
|
|
149
|
+
response = await client.post("/v1/connect-links", json=payload)
|
|
150
|
+
except httpx.RequestError:
|
|
151
|
+
# `from None`: `RequestError.request.headers` carries the bearer.
|
|
152
|
+
msg = "Ciridae MCP could not be reached"
|
|
153
|
+
raise CiridaeMcpApiError(msg) from None
|
|
154
|
+
_raise_for_status_or_error(response)
|
|
155
|
+
link = _parse_link(response.content)
|
|
156
|
+
if link is None:
|
|
157
|
+
msg = "Ciridae MCP returned a connect link that could not be read"
|
|
158
|
+
raise CiridaeMcpApiError(msg)
|
|
159
|
+
if (
|
|
160
|
+
link.user_id != user_id
|
|
161
|
+
or link.access != access
|
|
162
|
+
or _selection_set(link.tools) != _selection_set(tools)
|
|
163
|
+
):
|
|
164
|
+
msg = "Ciridae MCP minted a connect link for a different grant"
|
|
165
|
+
raise CiridaeMcpApiError(msg)
|
|
166
|
+
return link
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _selection_set(
|
|
170
|
+
tools: tuple[ToolOperation, ...] | None,
|
|
171
|
+
) -> frozenset[ToolOperation] | None:
|
|
172
|
+
"""Compare selections by membership; order on the wire is not semantic.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The selection as a set, or ``None`` when no selection was made.
|
|
176
|
+
"""
|
|
177
|
+
return None if tools is None else frozenset(tools)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _raise_for_status_or_error(response: httpx.Response) -> None:
|
|
181
|
+
"""Return for a 2xx mint response, otherwise raise.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
response: The gateway's mint response.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
CiridaeMcpApiError: For any non-2xx status.
|
|
188
|
+
"""
|
|
189
|
+
if HTTPStatus.OK <= response.status_code < HTTPStatus.MULTIPLE_CHOICES:
|
|
190
|
+
return
|
|
191
|
+
# No body: a rejection can echo the request or carry a signed URL.
|
|
192
|
+
logger.warning("Ciridae MCP mint failed with status %s", response.status_code)
|
|
193
|
+
msg = f"Ciridae MCP rejected mint (status={response.status_code})"
|
|
194
|
+
raise CiridaeMcpApiError(msg, status_code=response.status_code)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_link(content: bytes) -> ConnectLink | None:
|
|
198
|
+
"""Parse a `ConnectLink` from a gateway response body.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
content: The raw response body.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The parsed link, or `None` when the body cannot be read. Returning
|
|
205
|
+
rather than raising keeps `ValidationError` (which quotes its input)
|
|
206
|
+
out of both `__cause__` and `__context__`.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
return ConnectLink.model_validate_json(content)
|
|
210
|
+
except ValidationError:
|
|
211
|
+
return None
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Wire schema for a connect link returned by the Ciridae MCP gateway."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar, Self
|
|
6
|
+
|
|
7
|
+
from ciridae.mcp.access import ConnectCode, ConnectUserId, McpAccessMode
|
|
8
|
+
from ciridae.mcp.tool_operation import ToolOperation
|
|
9
|
+
from pydantic import AwareDatetime, BaseModel, ConfigDict, model_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConnectLink(BaseModel):
|
|
13
|
+
"""A single-use, expiring credential that connects one user's MCP client."""
|
|
14
|
+
|
|
15
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True)
|
|
16
|
+
|
|
17
|
+
code: ConnectCode
|
|
18
|
+
user_id: ConnectUserId
|
|
19
|
+
access: McpAccessMode
|
|
20
|
+
tools: tuple[ToolOperation, ...] | None = None
|
|
21
|
+
expires_at: AwareDatetime
|
|
22
|
+
|
|
23
|
+
@model_validator(mode="after")
|
|
24
|
+
def _selection_matches_the_access(self) -> Self:
|
|
25
|
+
"""Require an exact operation list only for selected access.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The validated connect link.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: The access mode and tool selection disagree.
|
|
32
|
+
"""
|
|
33
|
+
if (self.access == "selected") is not (self.tools is not None):
|
|
34
|
+
msg = f"access={self.access!r} does not match tools={self.tools!r}"
|
|
35
|
+
raise ValueError(msg)
|
|
36
|
+
return self
|