aiohttp-tiny-mcp 0.1.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.
- aiohttp_tiny_mcp/__init__.py +76 -0
- aiohttp_tiny_mcp/adapter.py +321 -0
- aiohttp_tiny_mcp/auth.py +129 -0
- aiohttp_tiny_mcp/client.py +111 -0
- aiohttp_tiny_mcp/client_base.py +295 -0
- aiohttp_tiny_mcp/console/__init__.py +98 -0
- aiohttp_tiny_mcp/console/console.css +525 -0
- aiohttp_tiny_mcp/console/console.js +1273 -0
- aiohttp_tiny_mcp/console/index.html +100 -0
- aiohttp_tiny_mcp/core.py +327 -0
- aiohttp_tiny_mcp/dispatcher.py +309 -0
- aiohttp_tiny_mcp/endpoint.py +531 -0
- aiohttp_tiny_mcp/exchange.py +279 -0
- aiohttp_tiny_mcp/http_sse.py +267 -0
- aiohttp_tiny_mcp/hub.py +109 -0
- aiohttp_tiny_mcp/models.py +346 -0
- aiohttp_tiny_mcp/namespaces.py +36 -0
- aiohttp_tiny_mcp/postgres.py +454 -0
- aiohttp_tiny_mcp/protocol/__init__.py +0 -0
- aiohttp_tiny_mcp/protocol/selection.py +92 -0
- aiohttp_tiny_mcp/protocol/v2024_11_05.py +30 -0
- aiohttp_tiny_mcp/protocol/v2025_03_26.py +165 -0
- aiohttp_tiny_mcp/protocol/v2025_06_18.py +11 -0
- aiohttp_tiny_mcp/protocol/v2025_11_25.py +164 -0
- aiohttp_tiny_mcp/protocol/v2026_07_28.py +363 -0
- aiohttp_tiny_mcp/py.typed +0 -0
- aiohttp_tiny_mcp/redis.py +195 -0
- aiohttp_tiny_mcp/registry.py +162 -0
- aiohttp_tiny_mcp/request_state.py +107 -0
- aiohttp_tiny_mcp/schema.py +131 -0
- aiohttp_tiny_mcp/sessions.py +347 -0
- aiohttp_tiny_mcp/specs.py +268 -0
- aiohttp_tiny_mcp/sqlite.py +236 -0
- aiohttp_tiny_mcp/sse.py +260 -0
- aiohttp_tiny_mcp/stdio.py +187 -0
- aiohttp_tiny_mcp/stdio_client.py +91 -0
- aiohttp_tiny_mcp/subscriptions.py +124 -0
- aiohttp_tiny_mcp/testing.py +200 -0
- aiohttp_tiny_mcp-0.1.0.dist-info/METADATA +12 -0
- aiohttp_tiny_mcp-0.1.0.dist-info/RECORD +41 -0
- aiohttp_tiny_mcp-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Public API: declare tools, resources and prompts once, revision-free."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .auth import Authorization, Principal
|
|
9
|
+
from .exchange import Exchange, Instance
|
|
10
|
+
from .hub import Hub, MemoryHub
|
|
11
|
+
from .models import Implementation
|
|
12
|
+
from .request_state import DEFAULT_TTL_SECONDS as STATE_TTL_SECONDS
|
|
13
|
+
from .request_state import RequestStates
|
|
14
|
+
from .sessions import DEFAULT_TTL_SECONDS, MemorySessionStore, SessionStore
|
|
15
|
+
from .specs import Bound, PromptSpec, ResourceSpec, ToolSpec
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Registry:
|
|
19
|
+
"""Register handlers as decorators or by passing functions directly.
|
|
20
|
+
|
|
21
|
+
@registry.tool
|
|
22
|
+
async def add(args: Add) -> int: ...
|
|
23
|
+
|
|
24
|
+
registry.tool(add, name="sum")
|
|
25
|
+
registry.resource("config://app", config)
|
|
26
|
+
|
|
27
|
+
Uses process-local memory backends by default. Supply shared `hub` and
|
|
28
|
+
`session_store` implementations before running more than one worker.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
name: str,
|
|
34
|
+
version: str,
|
|
35
|
+
*,
|
|
36
|
+
hub: Hub | None = None,
|
|
37
|
+
session_store: SessionStore | None = None,
|
|
38
|
+
auth: Authorization | None = None,
|
|
39
|
+
instructions: str | None = None,
|
|
40
|
+
session_ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
41
|
+
request_state_ttl_seconds: int = STATE_TTL_SECONDS,
|
|
42
|
+
hub_poll_seconds: float = 30.0,
|
|
43
|
+
ask_timeout_seconds: float = 120.0,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.info = Implementation(name=name, version=version)
|
|
46
|
+
self.instructions = instructions
|
|
47
|
+
self.hub = hub if hub is not None else MemoryHub()
|
|
48
|
+
self.session_store = session_store if session_store is not None else MemorySessionStore()
|
|
49
|
+
self.auth = auth
|
|
50
|
+
self.session_ttl_seconds = session_ttl_seconds
|
|
51
|
+
self.request_state = RequestStates(self.session_store, request_state_ttl_seconds)
|
|
52
|
+
self.hub_poll_seconds = hub_poll_seconds
|
|
53
|
+
self.ask_timeout_seconds = ask_timeout_seconds
|
|
54
|
+
self.tools: dict[str, ToolSpec] = {}
|
|
55
|
+
self.resources_fixed: dict[str, ResourceSpec] = {}
|
|
56
|
+
self.resources_templated: list[ResourceSpec] = []
|
|
57
|
+
self.prompts: dict[str, PromptSpec] = {}
|
|
58
|
+
self.completer: Bound | None = None
|
|
59
|
+
self.providers: dict[type, Any] = {}
|
|
60
|
+
|
|
61
|
+
def provide(self, kind: type, source: Any) -> None:
|
|
62
|
+
"""Bind a type to where it comes from. Call before registering
|
|
63
|
+
handlers -- plans are checked at registration, not at first call.
|
|
64
|
+
|
|
65
|
+
`source` is an async callable taking the exchange, an async generator
|
|
66
|
+
for anything that must be released afterwards, or a `web.AppKey`. For
|
|
67
|
+
an object that already exists, use `provide_instance` instead.
|
|
68
|
+
"""
|
|
69
|
+
self.providers[kind] = source
|
|
70
|
+
|
|
71
|
+
def provide_instance(self, value: Any, kind: type | None = None) -> None:
|
|
72
|
+
"""Register an existing object under its type, or under an explicit `kind` such as a base
|
|
73
|
+
class or protocol. Use `provide` for per-request construction or cleanup.
|
|
74
|
+
"""
|
|
75
|
+
if kind is None and isinstance(value, type):
|
|
76
|
+
raise TypeError(
|
|
77
|
+
f"provide_instance was given the class {value.__name__} rather than an "
|
|
78
|
+
f"instance of it. Pass the object, or say kind=type to provide the class "
|
|
79
|
+
f"itself."
|
|
80
|
+
)
|
|
81
|
+
self.providers[kind or type(value)] = Instance(value)
|
|
82
|
+
|
|
83
|
+
def check(self, what: str, bound: Bound) -> None:
|
|
84
|
+
for name, kind in bound.plan:
|
|
85
|
+
if kind is Principal:
|
|
86
|
+
if self.auth is None:
|
|
87
|
+
raise TypeError(
|
|
88
|
+
f"{what}: parameter {name!r} wants a Principal, but this registry "
|
|
89
|
+
f"has no auth= -- pass an Authorization to verify tokens first"
|
|
90
|
+
)
|
|
91
|
+
continue
|
|
92
|
+
if kind not in self.providers and kind is not Exchange:
|
|
93
|
+
label = getattr(kind, "__name__", kind)
|
|
94
|
+
raise TypeError(
|
|
95
|
+
f"{what}: parameter {name!r} wants {label}, which has no provider -- "
|
|
96
|
+
f"call registry.provide_instance() or registry.provide() first"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def tool(self, fn: Callable[..., Awaitable[Any]] | None = None, **kw: Any):
|
|
100
|
+
def register(fn: Callable[..., Awaitable[Any]]):
|
|
101
|
+
spec = ToolSpec.build(fn, **kw)
|
|
102
|
+
if spec.scopes and self.auth is None:
|
|
103
|
+
raise TypeError(
|
|
104
|
+
f"{spec.name}: scopes were declared, but this registry has no auth= -- "
|
|
105
|
+
f"a scope nothing verifies is a check that never runs"
|
|
106
|
+
)
|
|
107
|
+
self.check(spec.name, spec.bound)
|
|
108
|
+
if spec.name in self.tools:
|
|
109
|
+
raise ValueError(f"duplicate tool: {spec.name}")
|
|
110
|
+
self.tools[spec.name] = spec
|
|
111
|
+
return fn
|
|
112
|
+
|
|
113
|
+
return register(fn) if fn else register
|
|
114
|
+
|
|
115
|
+
def resource(self, uri: str, fn: Callable[..., Awaitable[Any]] | None = None, **kw: Any):
|
|
116
|
+
def register(fn: Callable[..., Awaitable[Any]]):
|
|
117
|
+
spec = ResourceSpec.build(uri, fn, **kw)
|
|
118
|
+
self.check(uri, spec.bound)
|
|
119
|
+
if spec.definition is not None:
|
|
120
|
+
if spec.definition.uri in self.resources_fixed:
|
|
121
|
+
raise ValueError(f"duplicate resource: {spec.definition.uri}")
|
|
122
|
+
self.resources_fixed[spec.definition.uri] = spec
|
|
123
|
+
else:
|
|
124
|
+
template = spec.template.uri_template if spec.template is not None else uri
|
|
125
|
+
if any(
|
|
126
|
+
item.template is not None and item.template.uri_template == template
|
|
127
|
+
for item in self.resources_templated
|
|
128
|
+
):
|
|
129
|
+
raise ValueError(f"duplicate resource template: {template}")
|
|
130
|
+
self.resources_templated.append(spec)
|
|
131
|
+
return fn
|
|
132
|
+
|
|
133
|
+
return register(fn) if fn else register
|
|
134
|
+
|
|
135
|
+
def prompt(self, fn: Callable[..., Awaitable[Any]] | None = None, **kw: Any):
|
|
136
|
+
def register(fn: Callable[..., Awaitable[Any]]):
|
|
137
|
+
spec = PromptSpec.build(fn, **kw)
|
|
138
|
+
self.check(spec.name, spec.bound)
|
|
139
|
+
if spec.name in self.prompts:
|
|
140
|
+
raise ValueError(f"duplicate prompt: {spec.name}")
|
|
141
|
+
self.prompts[spec.name] = spec
|
|
142
|
+
return fn
|
|
143
|
+
|
|
144
|
+
return register(fn) if fn else register
|
|
145
|
+
|
|
146
|
+
def completions(self, fn: Callable[..., Awaitable[Any]]):
|
|
147
|
+
"""(CompleteParams-shaped model, deps) -> list[str] | Completion."""
|
|
148
|
+
if self.completer is not None:
|
|
149
|
+
raise ValueError("duplicate completions handler")
|
|
150
|
+
self.completer = Bound.of(fn)
|
|
151
|
+
self.check("completions", self.completer)
|
|
152
|
+
return fn
|
|
153
|
+
|
|
154
|
+
def match_resource(self, uri: str) -> tuple[ResourceSpec, dict[str, str]] | None:
|
|
155
|
+
fixed = self.resources_fixed.get(uri)
|
|
156
|
+
if fixed is not None:
|
|
157
|
+
return fixed, {}
|
|
158
|
+
for spec in self.resources_templated:
|
|
159
|
+
m = spec.pattern.match(uri)
|
|
160
|
+
if m:
|
|
161
|
+
return spec, m.groupdict()
|
|
162
|
+
return None
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Store handler state between input rounds; clients carry only an opaque id.
|
|
2
|
+
|
|
3
|
+
Ids are bound to the original call, scoped by namespace, and expire independently of sessions.
|
|
4
|
+
Shared storage allows retries on another node.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import secrets
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .core import Call, Failure, FailureKind, NeedsInput
|
|
16
|
+
from .namespaces import scoped
|
|
17
|
+
from .sessions import SessionStore
|
|
18
|
+
|
|
19
|
+
PREFIX = "state/"
|
|
20
|
+
|
|
21
|
+
DEFAULT_TTL_SECONDS = 600
|
|
22
|
+
|
|
23
|
+
BINDING_KEY = "binding"
|
|
24
|
+
PAYLOAD_KEY = "payload"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def binding_of(call: Call) -> str:
|
|
28
|
+
"""Hash normalized call arguments and target so state cannot be reused for a different call."""
|
|
29
|
+
material = json.dumps(
|
|
30
|
+
{
|
|
31
|
+
"operation": call.operation.value,
|
|
32
|
+
"target": call.target,
|
|
33
|
+
"arguments": call.arguments,
|
|
34
|
+
},
|
|
35
|
+
ensure_ascii=False,
|
|
36
|
+
sort_keys=True,
|
|
37
|
+
separators=(",", ":"),
|
|
38
|
+
)
|
|
39
|
+
return hashlib.sha256(material.encode()).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class RequestStates:
|
|
44
|
+
"""State rows, in the store the deployment already supplies."""
|
|
45
|
+
|
|
46
|
+
store: SessionStore
|
|
47
|
+
ttl_seconds: int = DEFAULT_TTL_SECONDS
|
|
48
|
+
|
|
49
|
+
def key(self, state_id: str) -> str:
|
|
50
|
+
return scoped(PREFIX + state_id)
|
|
51
|
+
|
|
52
|
+
async def open(self, call: Call, payload: Any) -> str:
|
|
53
|
+
"""Store payload and return its opaque client-visible id."""
|
|
54
|
+
state_id = secrets.token_urlsafe(24)
|
|
55
|
+
created = await self.store.create(
|
|
56
|
+
self.key(state_id),
|
|
57
|
+
{PAYLOAD_KEY: payload, BINDING_KEY: binding_of(call)},
|
|
58
|
+
ttl_seconds=self.ttl_seconds,
|
|
59
|
+
)
|
|
60
|
+
if not created:
|
|
61
|
+
raise RuntimeError("the store refused a fresh request-state id")
|
|
62
|
+
return state_id
|
|
63
|
+
|
|
64
|
+
async def read(self, call: Call, state_id: str) -> Any:
|
|
65
|
+
"""Read the payload; raise KeyError for missing state or a different call binding."""
|
|
66
|
+
record = await self.store.get(self.key(state_id))
|
|
67
|
+
if record is None or record.data.get(BINDING_KEY) != binding_of(call):
|
|
68
|
+
raise KeyError(state_id)
|
|
69
|
+
return record.data.get(PAYLOAD_KEY)
|
|
70
|
+
|
|
71
|
+
async def drop(self, state_id: str) -> None:
|
|
72
|
+
await self.store.delete(self.key(state_id))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def restore_request_state(call: Call, states: RequestStates) -> Failure | None:
|
|
76
|
+
"""Replace the id the client sent with the state the handler left."""
|
|
77
|
+
if call.state is None:
|
|
78
|
+
return None
|
|
79
|
+
if not isinstance(call.state, str):
|
|
80
|
+
return Failure(FailureKind.INVALID_PARAMS, "invalid requestState")
|
|
81
|
+
try:
|
|
82
|
+
call.state = await states.read(call, call.state)
|
|
83
|
+
except KeyError:
|
|
84
|
+
return Failure(FailureKind.INVALID_PARAMS, "invalid requestState")
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def protect_request_state(
|
|
89
|
+
call: Call, outcome: NeedsInput, states: RequestStates
|
|
90
|
+
) -> NeedsInput | Failure:
|
|
91
|
+
"""Keep the handler's state and hand the client its id instead."""
|
|
92
|
+
if outcome.state is None:
|
|
93
|
+
return outcome
|
|
94
|
+
try:
|
|
95
|
+
state_id = await states.open(call, outcome.state)
|
|
96
|
+
except (RuntimeError, TypeError) as e:
|
|
97
|
+
return Failure(FailureKind.INTERNAL, f"requestState could not be stored: {e}")
|
|
98
|
+
return NeedsInput(requests=outcome.requests, state=state_id)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = [
|
|
102
|
+
"DEFAULT_TTL_SECONDS",
|
|
103
|
+
"RequestStates",
|
|
104
|
+
"binding_of",
|
|
105
|
+
"protect_request_state",
|
|
106
|
+
"restore_request_state",
|
|
107
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""JSON Schema generation and simplification for legacy clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
HEADER_ANNOTATION = "x-mcp-header"
|
|
12
|
+
HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
|
|
13
|
+
HeaderParam = tuple[tuple[str, ...], str]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def json_schema(model: type[BaseModel]) -> dict:
|
|
17
|
+
"""Generate JSON Schema 2020-12 with refs intact; legacy adapters simplify it separately."""
|
|
18
|
+
schema = model.model_json_schema()
|
|
19
|
+
schema.setdefault("$schema", "https://json-schema.org/draft/2020-12/schema")
|
|
20
|
+
schema.setdefault("type", "object")
|
|
21
|
+
schema.setdefault("properties", {})
|
|
22
|
+
return schema
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def inline_refs(schema: dict[str, Any]) -> dict[str, Any]:
|
|
26
|
+
"""Inline refs for legacy clients. Recursive schemas are not supported."""
|
|
27
|
+
defs = schema.get("$defs", {})
|
|
28
|
+
|
|
29
|
+
def walk(node: Any) -> Any:
|
|
30
|
+
if isinstance(node, dict):
|
|
31
|
+
ref = node.get("$ref")
|
|
32
|
+
if isinstance(ref, str) and ref.startswith("#/$defs/"):
|
|
33
|
+
target = walk(copy.deepcopy(defs[ref.rsplit("/", 1)[1]]))
|
|
34
|
+
return {**target, **{k: v for k, v in node.items() if k != "$ref"}}
|
|
35
|
+
return {k: walk(v) for k, v in node.items() if k != "$defs"}
|
|
36
|
+
if isinstance(node, list):
|
|
37
|
+
return [walk(x) for x in node]
|
|
38
|
+
return node
|
|
39
|
+
|
|
40
|
+
return walk(schema)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def header_params(schema: dict[str, Any]) -> tuple[HeaderParam, ...]:
|
|
44
|
+
"""Return (property_path, header_suffix) pairs for reachable x-mcp-header declarations. Reject
|
|
45
|
+
invalid declarations at registration.
|
|
46
|
+
"""
|
|
47
|
+
found: list[HeaderParam] = []
|
|
48
|
+
seen: set[str] = set()
|
|
49
|
+
definitions = schema.get("$defs", {})
|
|
50
|
+
|
|
51
|
+
def walk(node: Any, path: tuple[str, ...], active_refs: frozenset[str] = frozenset()) -> None:
|
|
52
|
+
if not isinstance(node, dict):
|
|
53
|
+
return
|
|
54
|
+
ref = node.get("$ref")
|
|
55
|
+
if isinstance(ref, str) and ref.startswith("#/$defs/") and ref not in active_refs:
|
|
56
|
+
target = definitions.get(ref.rsplit("/", 1)[1])
|
|
57
|
+
if isinstance(target, dict):
|
|
58
|
+
walk(
|
|
59
|
+
{**target, **{key: value for key, value in node.items() if key != "$ref"}},
|
|
60
|
+
path,
|
|
61
|
+
active_refs | {ref},
|
|
62
|
+
)
|
|
63
|
+
return
|
|
64
|
+
properties = node.get("properties")
|
|
65
|
+
if not isinstance(properties, dict):
|
|
66
|
+
return
|
|
67
|
+
for property_name, property_schema in properties.items():
|
|
68
|
+
if not isinstance(property_schema, dict):
|
|
69
|
+
continue
|
|
70
|
+
property_path = (*path, property_name)
|
|
71
|
+
annotation = property_schema.get(HEADER_ANNOTATION)
|
|
72
|
+
if annotation is not None:
|
|
73
|
+
if (
|
|
74
|
+
not isinstance(annotation, str)
|
|
75
|
+
or not annotation
|
|
76
|
+
or not HEADER_NAME.fullmatch(annotation)
|
|
77
|
+
):
|
|
78
|
+
raise ValueError(f"invalid x-mcp-header value at {'.'.join(property_path)}")
|
|
79
|
+
if property_schema.get("type") not in {"string", "integer", "boolean"}:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"x-mcp-header requires string, integer, or boolean at "
|
|
82
|
+
f"{'.'.join(property_path)}"
|
|
83
|
+
)
|
|
84
|
+
folded = annotation.casefold()
|
|
85
|
+
if folded in seen:
|
|
86
|
+
raise ValueError(f"duplicate x-mcp-header value: {annotation}")
|
|
87
|
+
seen.add(folded)
|
|
88
|
+
found.append((property_path, annotation))
|
|
89
|
+
walk(property_schema, property_path, active_refs)
|
|
90
|
+
|
|
91
|
+
walk(schema, ())
|
|
92
|
+
return tuple(found)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def simplify_legacy_schema(schema: dict) -> dict | None:
|
|
96
|
+
"""Best-effort schema simplification for pre-2026-07-28 clients."""
|
|
97
|
+
node = _simplify_node(inline_refs(schema))
|
|
98
|
+
if node is None or node.get("type") != "object":
|
|
99
|
+
return None
|
|
100
|
+
return node
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _simplify_node(node: Any) -> Any:
|
|
104
|
+
if not isinstance(node, dict):
|
|
105
|
+
return node
|
|
106
|
+
node = {k: v for k, v in node.items() if k not in ("if", "then", "else")}
|
|
107
|
+
any_of = node.get("anyOf")
|
|
108
|
+
if isinstance(any_of, list):
|
|
109
|
+
branches = [
|
|
110
|
+
branch
|
|
111
|
+
for branch in any_of
|
|
112
|
+
if not (isinstance(branch, dict) and branch.get("type") == "null")
|
|
113
|
+
]
|
|
114
|
+
if len(branches) != 1:
|
|
115
|
+
return None
|
|
116
|
+
branch = _simplify_node(branches[0])
|
|
117
|
+
if branch is None:
|
|
118
|
+
return None
|
|
119
|
+
node = {**{k: v for k, v in node.items() if k != "anyOf"}, **branch}
|
|
120
|
+
if "oneOf" in node:
|
|
121
|
+
return None
|
|
122
|
+
properties = node.get("properties")
|
|
123
|
+
if isinstance(properties, dict):
|
|
124
|
+
simplified = {}
|
|
125
|
+
for key, prop in properties.items():
|
|
126
|
+
prop = _simplify_node(prop)
|
|
127
|
+
if prop is None:
|
|
128
|
+
return None
|
|
129
|
+
simplified[key] = prop
|
|
130
|
+
node = {**node, "properties": simplified}
|
|
131
|
+
return node
|