craft-suite-api 0.2.0__py3-none-any.whl
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.
- craft_suite_api/__init__.py +51 -0
- craft_suite_api/__main__.py +4 -0
- craft_suite_api/api/__init__.py +3 -0
- craft_suite_api/api/api_client_system.py +7 -0
- craft_suite_api/auth.py +92 -0
- craft_suite_api/connector_manifest.yml +47 -0
- craft_suite_api/connectors/__init__.py +0 -0
- craft_suite_api/connectors/mcp_source_presets.json +1 -0
- craft_suite_api/connectors/tool_schema_fingerprints.json +6 -0
- craft_suite_api/error_authority.py +140 -0
- craft_suite_api/mcp/__init__.py +20 -0
- craft_suite_api/mcp_config.json +3 -0
- craft_suite_api/mcp_server.py +50 -0
- craft_suite_api/ontology/__init__.py +6 -0
- craft_suite_api/ontology/artcraft.ttl +29 -0
- craft_suite_api/openapi_auth_scheme.py +67 -0
- craft_suite_api/openapi_client.py +121 -0
- craft_suite_api/openapi_contract.json +4116 -0
- craft_suite_api/openapi_execution.py +71 -0
- craft_suite_api/openapi_parameters.py +121 -0
- craft_suite_api/openapi_responses.py +44 -0
- craft_suite_api/openapi_runtime.py +55 -0
- craft_suite_api/openapi_schema.py +42 -0
- craft_suite_api/problem_details.py +204 -0
- craft_suite_api/prompts/__init__.py +0 -0
- craft_suite_api/prompts/artcraft_specialist.json +36 -0
- craft_suite_api/skills/__init__.py +0 -0
- craft_suite_api/skills/artcraft-starter/SKILL.md +29 -0
- craft_suite_api-0.2.0.dist-info/METADATA +170 -0
- craft_suite_api-0.2.0.dist-info/RECORD +34 -0
- craft_suite_api-0.2.0.dist-info/WHEEL +5 -0
- craft_suite_api-0.2.0.dist-info/entry_points.txt +14 -0
- craft_suite_api-0.2.0.dist-info/licenses/LICENSE +21 -0
- craft_suite_api-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import inspect
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
warnings.filterwarnings("ignore", message=".*urllib3.*or chardet.*")
|
|
8
|
+
|
|
9
|
+
__all__: list[str] = []
|
|
10
|
+
|
|
11
|
+
CORE_MODULES = [
|
|
12
|
+
"craft_suite_api.api",
|
|
13
|
+
"craft_suite_api.error_authority",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
OPTIONAL_MODULES = {
|
|
17
|
+
"craft_suite_api.mcp_server": "mcp",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _import_module_safely(module_name: str):
|
|
22
|
+
"""Try to import a module and return it, or None if not available."""
|
|
23
|
+
try:
|
|
24
|
+
return importlib.import_module(module_name)
|
|
25
|
+
except ImportError:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _expose_members(module):
|
|
30
|
+
"""Expose public classes and functions from a module into globals and __all__."""
|
|
31
|
+
for name, obj in inspect.getmembers(module):
|
|
32
|
+
if (inspect.isclass(obj) or inspect.isfunction(obj)) and not name.startswith(
|
|
33
|
+
"_"
|
|
34
|
+
):
|
|
35
|
+
globals()[name] = obj
|
|
36
|
+
__all__.append(name)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
for module_name in CORE_MODULES:
|
|
40
|
+
module = importlib.import_module(module_name)
|
|
41
|
+
_expose_members(module)
|
|
42
|
+
|
|
43
|
+
for module_name, extra_name in OPTIONAL_MODULES.items():
|
|
44
|
+
module = _import_module_safely(module_name)
|
|
45
|
+
if module is not None:
|
|
46
|
+
_expose_members(module)
|
|
47
|
+
globals()[f"_{extra_name.upper()}_AVAILABLE"] = True
|
|
48
|
+
else:
|
|
49
|
+
globals()[f"_{extra_name.upper()}_AVAILABLE"] = False
|
|
50
|
+
|
|
51
|
+
__all__.extend(["_MCP_AVAILABLE"])
|
craft_suite_api/auth.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Resolve provider credentials through canonical SDK APIs and owned resources."""
|
|
2
|
+
|
|
3
|
+
from contextlib import ExitStack
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from agent_connector_sdk.auth.delegation import DelegatedTokenAuth, DelegationSettings
|
|
7
|
+
from agent_connector_sdk.auth.static import api_key_auth, bearer_auth
|
|
8
|
+
from agent_connector_sdk.config import load_config, setting
|
|
9
|
+
from agent_connector_sdk.http.client import create_http_client
|
|
10
|
+
from agent_connector_sdk.http.options import HttpClientOptions
|
|
11
|
+
from agent_connector_sdk.tls.resolve import resolve_tls_profile
|
|
12
|
+
|
|
13
|
+
from .api import ApiClientSystem
|
|
14
|
+
from .openapi_auth_scheme import AuthKind, declared_scheme
|
|
15
|
+
from .openapi_client import ClientPolicy
|
|
16
|
+
from .openapi_schema import CONTRACT
|
|
17
|
+
|
|
18
|
+
_client = None
|
|
19
|
+
_resources = ExitStack()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _reference_auth(definition):
|
|
23
|
+
reference = setting("CRAFT_SUITE_API_TOKEN_REF", "")
|
|
24
|
+
if not reference:
|
|
25
|
+
return httpx.Auth(), None
|
|
26
|
+
if definition is None:
|
|
27
|
+
raise ValueError("Unsupported provider authentication scheme")
|
|
28
|
+
if definition.kind is AuthKind.HEADER:
|
|
29
|
+
return api_key_auth(reference, header=definition.header), definition.name
|
|
30
|
+
return bearer_auth(reference), definition.name
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _delegated_auth(definition, delegation, resources):
|
|
34
|
+
if definition is None or definition.kind is not AuthKind.BEARER:
|
|
35
|
+
raise ValueError("Delegation requires a declared HTTP bearer auth scheme")
|
|
36
|
+
token_tls = resolve_tls_profile("oidc")
|
|
37
|
+
resources.callback(token_tls.cleanup)
|
|
38
|
+
token_client = create_http_client(
|
|
39
|
+
HttpClientOptions(base_url=delegation.token_endpoint, tls=token_tls)
|
|
40
|
+
)
|
|
41
|
+
resources.callback(token_client.close)
|
|
42
|
+
return DelegatedTokenAuth(delegation, http_client=token_client), definition.name
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_client(base_url, resources):
|
|
46
|
+
scheme = setting("CRAFT_SUITE_API_AUTH_SCHEME", "signed_session")
|
|
47
|
+
definition = declared_scheme(scheme, CONTRACT["security_schemes"])
|
|
48
|
+
tls = resolve_tls_profile("craft-suite-api")
|
|
49
|
+
resources.callback(tls.cleanup)
|
|
50
|
+
delegation = DelegationSettings.from_settings()
|
|
51
|
+
if delegation.enabled:
|
|
52
|
+
auth, credential_scheme = _delegated_auth(definition, delegation, resources)
|
|
53
|
+
else:
|
|
54
|
+
auth, credential_scheme = _reference_auth(definition)
|
|
55
|
+
policy = ClientPolicy(
|
|
56
|
+
allow_writes=setting("CRAFT_SUITE_API_ALLOW_WRITES", False),
|
|
57
|
+
allow_plaintext=setting("CRAFT_SUITE_API_ALLOW_PLAINTEXT", False),
|
|
58
|
+
)
|
|
59
|
+
client = ApiClientSystem(
|
|
60
|
+
base_url=base_url,
|
|
61
|
+
auth=auth,
|
|
62
|
+
tls_profile=tls,
|
|
63
|
+
credential_scheme=credential_scheme,
|
|
64
|
+
policy=policy,
|
|
65
|
+
)
|
|
66
|
+
resources.callback(client.close)
|
|
67
|
+
return client
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_client():
|
|
71
|
+
"""Build one client; delegated auth resolves the verified caller per request."""
|
|
72
|
+
global _client, _resources
|
|
73
|
+
if _client is not None:
|
|
74
|
+
return _client
|
|
75
|
+
load_config()
|
|
76
|
+
base_url = setting("CRAFT_SUITE_API_BASE_URL", "")
|
|
77
|
+
if not base_url:
|
|
78
|
+
raise ValueError("Provider base URL is required")
|
|
79
|
+
with ExitStack() as resources:
|
|
80
|
+
client = _build_client(base_url, resources)
|
|
81
|
+
_resources = resources.pop_all()
|
|
82
|
+
_client = client
|
|
83
|
+
return client
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def close_client():
|
|
87
|
+
"""Close the cached client, token client and materialized TLS resources."""
|
|
88
|
+
global _client
|
|
89
|
+
try:
|
|
90
|
+
_resources.close()
|
|
91
|
+
finally:
|
|
92
|
+
_client = None
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"connector": "craft-suite-api",
|
|
3
|
+
"ontology_source": "artcraft",
|
|
4
|
+
"schema_version": "1",
|
|
5
|
+
"resources": [
|
|
6
|
+
{
|
|
7
|
+
"name": "ArtcraftResource"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"name": "Document"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"name": "MediaAsset"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"identity": {
|
|
17
|
+
"id_field": {
|
|
18
|
+
"ArtcraftResource": "id"
|
|
19
|
+
},
|
|
20
|
+
"title_field": {
|
|
21
|
+
"ArtcraftResource": "name"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"schema_mappings": {
|
|
25
|
+
"ArtcraftResource": {
|
|
26
|
+
"fields": {
|
|
27
|
+
"name": "xsd:string"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"sync": [],
|
|
32
|
+
"provenance": {
|
|
33
|
+
"generated_by": "mcp-builder/scripts/scaffold_package.py",
|
|
34
|
+
"source_artifacts": [
|
|
35
|
+
"ontology/artcraft.ttl"
|
|
36
|
+
],
|
|
37
|
+
"integrity": {
|
|
38
|
+
"algorithm": "sha256",
|
|
39
|
+
"hash": "a759f5a7effd1151539a1f9918225939ea8bc2484ff28b36852310e054d1d9db"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"review_todos": [
|
|
43
|
+
"Replace the starter resource mapping with the reviewed source model.",
|
|
44
|
+
"Declare real source presets and matching sync entries before certification.",
|
|
45
|
+
"Integrity is an ontology byte hash, not canonical RDF or release certification."
|
|
46
|
+
]
|
|
47
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Negotiate representations of the canonical bounded problem details."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from .problem_details import (
|
|
7
|
+
AGENT_ERROR_ACCEPT,
|
|
8
|
+
BROWSER_HTML_MEDIA_TYPE,
|
|
9
|
+
MAX_DETAIL_CHARS,
|
|
10
|
+
PROBLEM_JSON_MEDIA_TYPE,
|
|
11
|
+
STRUCTURED_MARKDOWN_MEDIA_TYPE,
|
|
12
|
+
ProblemDetails,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class RenderedError:
|
|
18
|
+
"""A transport-neutral response ready for an HTTP/MCP adapter."""
|
|
19
|
+
|
|
20
|
+
status_code: int
|
|
21
|
+
media_type: str
|
|
22
|
+
body: str
|
|
23
|
+
headers: Mapping[str, str]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _media_candidate(item: str, offered: str, order: int):
|
|
27
|
+
bits = [part.strip() for part in item.split(";")]
|
|
28
|
+
token = bits[0].lower()
|
|
29
|
+
try:
|
|
30
|
+
quality = next(
|
|
31
|
+
float(part.split("=", 1)[1].strip())
|
|
32
|
+
for part in bits[1:]
|
|
33
|
+
if part.lower().startswith("q=")
|
|
34
|
+
)
|
|
35
|
+
except (StopIteration, ValueError):
|
|
36
|
+
quality = 1.0
|
|
37
|
+
if not 0.0 <= quality <= 1.0:
|
|
38
|
+
return None
|
|
39
|
+
specificity = {offered: 2, "*/*": 1, offered.split("/", 1)[0] + "/*": 1}.get(token)
|
|
40
|
+
if specificity is None:
|
|
41
|
+
return None
|
|
42
|
+
return specificity, quality, -order
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _accept_quality(accept: str, offered: str) -> tuple[float, int, int]:
|
|
46
|
+
"""Return q, specificity, and source order for one offered media type."""
|
|
47
|
+
candidates = (
|
|
48
|
+
_media_candidate(item, offered, order)
|
|
49
|
+
for order, item in enumerate(accept.split(","))
|
|
50
|
+
)
|
|
51
|
+
specificity, quality, source_order = max(
|
|
52
|
+
(candidate for candidate in candidates if candidate is not None),
|
|
53
|
+
default=(-1, 0.0, -10_000),
|
|
54
|
+
)
|
|
55
|
+
return quality, specificity, source_order
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def negotiate_media_type(accept: str | None) -> str:
|
|
59
|
+
"""Prefer agent formats, while retaining HTML for browser Accept headers."""
|
|
60
|
+
if not accept or not accept.strip():
|
|
61
|
+
return PROBLEM_JSON_MEDIA_TYPE
|
|
62
|
+
offered = (
|
|
63
|
+
PROBLEM_JSON_MEDIA_TYPE,
|
|
64
|
+
STRUCTURED_MARKDOWN_MEDIA_TYPE,
|
|
65
|
+
BROWSER_HTML_MEDIA_TYPE,
|
|
66
|
+
)
|
|
67
|
+
ranked = [
|
|
68
|
+
(*_accept_quality(accept, media_type), -priority, media_type)
|
|
69
|
+
for priority, media_type in enumerate(offered)
|
|
70
|
+
]
|
|
71
|
+
supported = [candidate for candidate in ranked if candidate[0] > 0]
|
|
72
|
+
return max(supported)[-1] if supported else PROBLEM_JSON_MEDIA_TYPE
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render_error(
|
|
76
|
+
problem: ProblemDetails, *, accept: str | None = None
|
|
77
|
+
) -> RenderedError:
|
|
78
|
+
"""Negotiate one bounded representation without changing its semantics."""
|
|
79
|
+
media_type = negotiate_media_type(accept)
|
|
80
|
+
if media_type == STRUCTURED_MARKDOWN_MEDIA_TYPE:
|
|
81
|
+
body = problem.to_markdown()
|
|
82
|
+
elif media_type == BROWSER_HTML_MEDIA_TYPE:
|
|
83
|
+
body = problem.to_html()
|
|
84
|
+
else:
|
|
85
|
+
media_type = PROBLEM_JSON_MEDIA_TYPE
|
|
86
|
+
body = problem.to_json()
|
|
87
|
+
headers = {
|
|
88
|
+
"Content-Type": f"{media_type}; charset=utf-8",
|
|
89
|
+
"Vary": "Accept",
|
|
90
|
+
}
|
|
91
|
+
if problem.retry_after_s is not None:
|
|
92
|
+
headers["Retry-After"] = str(problem.retry_after_s)
|
|
93
|
+
return RenderedError(
|
|
94
|
+
status_code=problem.status,
|
|
95
|
+
media_type=media_type,
|
|
96
|
+
body=body,
|
|
97
|
+
headers=headers,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def error_response(
|
|
102
|
+
*,
|
|
103
|
+
status: int,
|
|
104
|
+
code: str,
|
|
105
|
+
instance: str,
|
|
106
|
+
detail: str,
|
|
107
|
+
type_uri: str = "",
|
|
108
|
+
title: str = "",
|
|
109
|
+
retryable: bool = False,
|
|
110
|
+
retry_after_s: float | None = None,
|
|
111
|
+
accept: str | None = None,
|
|
112
|
+
) -> RenderedError:
|
|
113
|
+
"""Construct and render a problem through the single package authority."""
|
|
114
|
+
return render_error(
|
|
115
|
+
ProblemDetails(
|
|
116
|
+
status=status,
|
|
117
|
+
code=code,
|
|
118
|
+
instance=instance,
|
|
119
|
+
detail=detail,
|
|
120
|
+
type_uri=type_uri,
|
|
121
|
+
title=title,
|
|
122
|
+
retryable=retryable,
|
|
123
|
+
retry_after_s=retry_after_s,
|
|
124
|
+
),
|
|
125
|
+
accept=accept,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
__all__ = [
|
|
130
|
+
"BROWSER_HTML_MEDIA_TYPE",
|
|
131
|
+
"AGENT_ERROR_ACCEPT",
|
|
132
|
+
"MAX_DETAIL_CHARS",
|
|
133
|
+
"PROBLEM_JSON_MEDIA_TYPE",
|
|
134
|
+
"ProblemDetails",
|
|
135
|
+
"RenderedError",
|
|
136
|
+
"STRUCTURED_MARKDOWN_MEDIA_TYPE",
|
|
137
|
+
"error_response",
|
|
138
|
+
"negotiate_media_type",
|
|
139
|
+
"render_error",
|
|
140
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Catalog-bound registrars preserving the SDK's per-domain toggles."""
|
|
2
|
+
|
|
3
|
+
from functools import partial
|
|
4
|
+
|
|
5
|
+
from ..auth import get_client
|
|
6
|
+
from ..openapi_runtime import CONTRACT, register_domain
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _domain_registrar(domain):
|
|
10
|
+
registrar = partial(register_domain, domain=domain, client_factory=get_client)
|
|
11
|
+
registrar.__name__ = f"register_{domain}_tools"
|
|
12
|
+
return registrar
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
globals().update(
|
|
16
|
+
{
|
|
17
|
+
f"register_{domain}_tools": _domain_registrar(domain)
|
|
18
|
+
for domain in sorted(CONTRACT["domains"])
|
|
19
|
+
}
|
|
20
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from agent_connector_sdk.config import load_config
|
|
5
|
+
from agent_connector_sdk.mcp.content import ConnectorContent
|
|
6
|
+
from agent_connector_sdk.mcp.server import create_mcp_server
|
|
7
|
+
from agent_connector_sdk.mcp.tool_surface import register_tool_surface
|
|
8
|
+
|
|
9
|
+
from . import mcp as tool_modules
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.0"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_mcp_instance(*, command_args=None):
|
|
15
|
+
"""Construct one SDK-governed server and its native package content."""
|
|
16
|
+
load_config()
|
|
17
|
+
package_root = Path(__file__).resolve().parent
|
|
18
|
+
args, mcp, middlewares = create_mcp_server(
|
|
19
|
+
name="Craft Suite",
|
|
20
|
+
version=__version__,
|
|
21
|
+
instructions="Craft Suite connector; nine reviewed ArtCraft backend reads",
|
|
22
|
+
command_args=command_args,
|
|
23
|
+
content=ConnectorContent(
|
|
24
|
+
connector="craft-suite-api",
|
|
25
|
+
package_root=package_root,
|
|
26
|
+
package_version=__version__,
|
|
27
|
+
manifest_path=package_root / "connector_manifest.yml",
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
register_tool_surface(mcp, service="craft-suite-api", tools_module=tool_modules)
|
|
31
|
+
for middleware in middlewares:
|
|
32
|
+
mcp.add_middleware(middleware)
|
|
33
|
+
return mcp, args, middlewares
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_server(*, command_args=None):
|
|
37
|
+
return get_mcp_instance(command_args=command_args)[0]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def mcp_server():
|
|
41
|
+
mcp, args, _ = get_mcp_instance()
|
|
42
|
+
print("Starting Craft Suite MCP", file=sys.stderr)
|
|
43
|
+
options = (
|
|
44
|
+
{} if args.transport == "stdio" else {"host": args.host, "port": args.port}
|
|
45
|
+
)
|
|
46
|
+
mcp.run(transport=args.transport, **options)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
mcp_server()
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""ArtCraft ontology contribution (CONCEPT:AU-KG.ontology.federation-provider-leg).
|
|
2
|
+
|
|
3
|
+
Data-only subpackage carrying ``artcraft.ttl`` (the ``owl:Ontology``
|
|
4
|
+
``http://knuckles.team/kg/artcraft`` module) which the agent-connector-sdk hub
|
|
5
|
+
federates via the ``agent_connector_sdk.ontology_providers`` entry-point.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
@prefix : <http://knuckles.team/kg#> .
|
|
2
|
+
@prefix owl: <http://www.w3.org/2002/07/owl#> .
|
|
3
|
+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
|
4
|
+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
|
|
5
|
+
|
|
6
|
+
# TODO: replace this stub with the real domain model. Classes/properties live in the
|
|
7
|
+
# SHARED : (kg#) namespace; only the owl:Ontology node gets the per-package IRI. Do NOT
|
|
8
|
+
# redefine classes another package owns (:MediaAsset/:Blob/:Document/:Person) — reuse them.
|
|
9
|
+
|
|
10
|
+
:ArtcraftResource a owl:Class ;
|
|
11
|
+
rdfs:label "ArtCraft Resource" ;
|
|
12
|
+
rdfs:comment "A primary object managed by ArtCraft (STUB — rename/expand)." .
|
|
13
|
+
|
|
14
|
+
:managedBy a owl:ObjectProperty ;
|
|
15
|
+
rdfs:label "managed by" ;
|
|
16
|
+
rdfs:comment "Links a ArtCraft resource to the Person responsible (STUB)." ;
|
|
17
|
+
rdfs:domain :ArtcraftResource ;
|
|
18
|
+
rdfs:range :Person .
|
|
19
|
+
|
|
20
|
+
:artcraftId a owl:DatatypeProperty ;
|
|
21
|
+
rdfs:label "artcraft id" ;
|
|
22
|
+
rdfs:comment "External identifier of the ArtCraft resource (STUB)." ;
|
|
23
|
+
rdfs:domain :ArtcraftResource ;
|
|
24
|
+
rdfs:range xsd:string .
|
|
25
|
+
|
|
26
|
+
<http://knuckles.team/kg/artcraft> a owl:Ontology ;
|
|
27
|
+
rdfs:label "ArtCraft Ontology" ;
|
|
28
|
+
rdfs:comment "ArtCraft domain extensions federated into the KG hub. STUB." ;
|
|
29
|
+
owl:imports <http://knuckles.team/kg> .
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Typed, declared provider authentication backed by SDK credential references."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthKind(StrEnum):
|
|
13
|
+
"""Supported wire representations; neither implies provider permissions."""
|
|
14
|
+
|
|
15
|
+
BEARER = "bearer"
|
|
16
|
+
HEADER = "header"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ProviderAuthScheme:
|
|
21
|
+
"""One named OpenAPI scheme, never a credential value or a default header."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
kind: AuthKind
|
|
25
|
+
header: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def declared_scheme(
|
|
29
|
+
name: str, definitions: Mapping[str, Any]
|
|
30
|
+
) -> ProviderAuthScheme | None:
|
|
31
|
+
"""Fail closed for an explicit unknown or unsupported scheme."""
|
|
32
|
+
if not name:
|
|
33
|
+
return None
|
|
34
|
+
if name not in definitions:
|
|
35
|
+
raise ValueError("Selected authentication scheme is not declared")
|
|
36
|
+
definition = definitions[name]
|
|
37
|
+
if not isinstance(definition, Mapping):
|
|
38
|
+
raise TypeError("Authentication scheme must be an OpenAPI object")
|
|
39
|
+
wire_scheme = definition.get("scheme", "")
|
|
40
|
+
if (
|
|
41
|
+
definition.get("type") == "http"
|
|
42
|
+
and isinstance(wire_scheme, str)
|
|
43
|
+
and wire_scheme.casefold() == "bearer"
|
|
44
|
+
):
|
|
45
|
+
return ProviderAuthScheme(name, AuthKind.BEARER)
|
|
46
|
+
if definition.get("type") != "apiKey" or definition.get("in") != "header":
|
|
47
|
+
raise ValueError("Unsupported provider authentication scheme")
|
|
48
|
+
return ProviderAuthScheme(name, AuthKind.HEADER, _credential_header(definition))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _credential_header(definition):
|
|
52
|
+
header = definition.get("name")
|
|
53
|
+
if not isinstance(header, str) or not re.fullmatch(
|
|
54
|
+
r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", header
|
|
55
|
+
):
|
|
56
|
+
raise ValueError("Invalid credential header name")
|
|
57
|
+
if header.casefold() in {
|
|
58
|
+
"host",
|
|
59
|
+
"connection",
|
|
60
|
+
"content-length",
|
|
61
|
+
"transfer-encoding",
|
|
62
|
+
"proxy-authorization",
|
|
63
|
+
"proxy-authenticate",
|
|
64
|
+
"cookie",
|
|
65
|
+
}:
|
|
66
|
+
raise ValueError("Credential header conflicts with HTTP transport")
|
|
67
|
+
return header
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Operation-bound HTTP dispatch with explicit caller policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from agent_connector_sdk.http.client import create_http_client
|
|
11
|
+
from agent_connector_sdk.http.options import HttpClientOptions
|
|
12
|
+
from agent_connector_sdk.http.retry import RetryPolicy
|
|
13
|
+
|
|
14
|
+
from .openapi_execution import require_authorization
|
|
15
|
+
from .openapi_parameters import parameters, request_body
|
|
16
|
+
from .openapi_responses import read_response, response_value
|
|
17
|
+
from .openapi_schema import CONTRACT, validate
|
|
18
|
+
|
|
19
|
+
MAX_BYTES = 16 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ClientPolicy:
|
|
24
|
+
"""Default-deny write and transport permissions from the operator."""
|
|
25
|
+
|
|
26
|
+
allow_writes: bool = False
|
|
27
|
+
allow_plaintext: bool = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OpenApiClient:
|
|
31
|
+
"""Dispatch only catalogued actions; perform each HTTP request exactly once."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_url,
|
|
36
|
+
auth,
|
|
37
|
+
tls_profile=None,
|
|
38
|
+
*,
|
|
39
|
+
credential_scheme=None,
|
|
40
|
+
policy: ClientPolicy | None = None,
|
|
41
|
+
transport=None,
|
|
42
|
+
):
|
|
43
|
+
policy = policy or ClientPolicy()
|
|
44
|
+
parsed = urlsplit(base_url)
|
|
45
|
+
if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
|
|
46
|
+
raise ValueError("Base URL must be the provider origin without a path")
|
|
47
|
+
if credential_scheme and parsed.scheme == "http" and not policy.allow_plaintext:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"Credential-bearing plaintext HTTP requires allow_plaintext"
|
|
50
|
+
)
|
|
51
|
+
self.session = create_http_client(
|
|
52
|
+
HttpClientOptions(
|
|
53
|
+
base_url=base_url,
|
|
54
|
+
auth=auth,
|
|
55
|
+
tls=tls_profile,
|
|
56
|
+
retry=RetryPolicy(max_attempts=1),
|
|
57
|
+
allow_plaintext=policy.allow_plaintext,
|
|
58
|
+
timeout=30.0,
|
|
59
|
+
),
|
|
60
|
+
transport=transport,
|
|
61
|
+
)
|
|
62
|
+
self.credential_scheme = credential_scheme
|
|
63
|
+
self.allow_writes = policy.allow_writes
|
|
64
|
+
|
|
65
|
+
def close(self):
|
|
66
|
+
self.session.close()
|
|
67
|
+
|
|
68
|
+
def _authorize(self, operation):
|
|
69
|
+
require_authorization(operation, CONTRACT)
|
|
70
|
+
if not operation["read_only"] and not self.allow_writes:
|
|
71
|
+
raise PermissionError("Writes require explicit operator enablement")
|
|
72
|
+
requirements = operation["security"]
|
|
73
|
+
if requirements and not any(
|
|
74
|
+
not choice or set(choice) == {self.credential_scheme}
|
|
75
|
+
for choice in requirements
|
|
76
|
+
):
|
|
77
|
+
raise PermissionError("Action requires a supported credential reference")
|
|
78
|
+
|
|
79
|
+
def execute(self, domain: str, arguments: dict[str, Any]):
|
|
80
|
+
if domain not in CONTRACT["domains"]:
|
|
81
|
+
raise ValueError("Unknown domain")
|
|
82
|
+
validate(arguments, CONTRACT["schemas"][domain][0])
|
|
83
|
+
operation = CONTRACT["operations"][arguments["action"]]
|
|
84
|
+
if operation["domain"] != domain:
|
|
85
|
+
raise ValueError("Action is outside this domain")
|
|
86
|
+
self._authorize(operation)
|
|
87
|
+
path, query, headers = parameters(operation, arguments)
|
|
88
|
+
target = self.session.base_url.join(path)
|
|
89
|
+
origin = self.session.base_url
|
|
90
|
+
if (target.scheme, target.host, target.port) != (
|
|
91
|
+
origin.scheme,
|
|
92
|
+
origin.host,
|
|
93
|
+
origin.port,
|
|
94
|
+
):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"Operation must stay within the configured provider origin"
|
|
97
|
+
)
|
|
98
|
+
options = {"params": query, "headers": headers, "follow_redirects": False}
|
|
99
|
+
if "body" in arguments:
|
|
100
|
+
options.update(
|
|
101
|
+
request_body(operation, arguments["body"], max_bytes=MAX_BYTES)
|
|
102
|
+
)
|
|
103
|
+
return self._send(operation, path, options)
|
|
104
|
+
|
|
105
|
+
def _send(self, operation, path, options):
|
|
106
|
+
deadline = time.monotonic() + 60
|
|
107
|
+
with self.session.stream(operation["method"], path, **options) as response:
|
|
108
|
+
if not 200 <= response.status_code < 300:
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
f"Provider returned HTTP {response.status_code}; no automatic retry"
|
|
111
|
+
)
|
|
112
|
+
body = read_response(response, deadline, max_bytes=MAX_BYTES)
|
|
113
|
+
content_type, data = response_value(operation, response, body)
|
|
114
|
+
result = {
|
|
115
|
+
"action": operation["action"],
|
|
116
|
+
"status": response.status_code,
|
|
117
|
+
"content_type": content_type,
|
|
118
|
+
"data": data,
|
|
119
|
+
}
|
|
120
|
+
validate(result, CONTRACT["schemas"][operation["domain"]][1])
|
|
121
|
+
return result
|