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,100 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-endpoint="{{ENDPOINT}}" data-console="{{BASE}}">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{TITLE}}</title>
|
|
7
|
+
<link rel="stylesheet" href="{{BASE}}/console.css">
|
|
8
|
+
<link rel="icon" href="data:,">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
|
|
12
|
+
<header class="bar">
|
|
13
|
+
<h1>{{TITLE}}</h1>
|
|
14
|
+
<span class="field">
|
|
15
|
+
<span>Endpoint</span>
|
|
16
|
+
<code id="endpoint" class="endpoint"></code>
|
|
17
|
+
</span>
|
|
18
|
+
<label class="field">
|
|
19
|
+
<span>Revision</span>
|
|
20
|
+
<select id="revision" class="revision"></select>
|
|
21
|
+
</label>
|
|
22
|
+
<label class="toggle" title="Answer a question the server asks, instead of refusing it">
|
|
23
|
+
<input id="answerable" type="checkbox" checked>
|
|
24
|
+
<span>Can be asked</span>
|
|
25
|
+
</label>
|
|
26
|
+
<button id="connect" class="primary">Connect</button>
|
|
27
|
+
<span id="status" class="status">not connected</span>
|
|
28
|
+
</header>
|
|
29
|
+
|
|
30
|
+
<main class="layout">
|
|
31
|
+
|
|
32
|
+
<nav class="panel catalogue" id="catalogue-panel">
|
|
33
|
+
<div class="panel-head">
|
|
34
|
+
<button id="catalogue-toggle" class="disclose" type="button" aria-expanded="true"
|
|
35
|
+
aria-controls="catalogue" title="Show or hide what this server offers">
|
|
36
|
+
<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">
|
|
37
|
+
<path d="M5 3l6 5-6 5" fill="none" stroke="currentColor"
|
|
38
|
+
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
|
39
|
+
</svg>
|
|
40
|
+
</button>
|
|
41
|
+
<h2>Offered</h2>
|
|
42
|
+
<button id="refresh" class="quiet" disabled>Refresh</button>
|
|
43
|
+
</div>
|
|
44
|
+
<div id="catalogue" class="scroll">
|
|
45
|
+
<p class="empty">Connect to see what this server offers.</p>
|
|
46
|
+
</div>
|
|
47
|
+
</nav>
|
|
48
|
+
|
|
49
|
+
<section class="panel work">
|
|
50
|
+
<div class="panel-head">
|
|
51
|
+
<h2 id="subject">Nothing selected</h2>
|
|
52
|
+
<button id="invoke" class="primary" type="submit" form="arguments" disabled>Call</button>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="scroll">
|
|
55
|
+
<p id="greeting" class="greeting">{{DESCRIPTION}}</p>
|
|
56
|
+
<div id="about" class="about"></div>
|
|
57
|
+
<form id="arguments" class="arguments"></form>
|
|
58
|
+
<div id="outcome" class="outcome"></div>
|
|
59
|
+
</div>
|
|
60
|
+
</section>
|
|
61
|
+
|
|
62
|
+
<section class="panel traffic" id="traffic-panel">
|
|
63
|
+
<div class="panel-head">
|
|
64
|
+
<button id="traffic-toggle" class="disclose" type="button" aria-expanded="true"
|
|
65
|
+
aria-controls="traffic" title="Show or hide the traffic">
|
|
66
|
+
<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">
|
|
67
|
+
<path d="M5 3l6 5-6 5" fill="none" stroke="currentColor"
|
|
68
|
+
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
|
69
|
+
</svg>
|
|
70
|
+
</button>
|
|
71
|
+
<h2>Traffic</h2>
|
|
72
|
+
<label class="toggle small">
|
|
73
|
+
<input id="follow" type="checkbox" checked>
|
|
74
|
+
<span>Follow</span>
|
|
75
|
+
</label>
|
|
76
|
+
<button id="clear" class="quiet">Clear</button>
|
|
77
|
+
</div>
|
|
78
|
+
<div id="traffic" class="scroll log">
|
|
79
|
+
<p class="empty">Every message, as it goes over the wire.</p>
|
|
80
|
+
</div>
|
|
81
|
+
</section>
|
|
82
|
+
|
|
83
|
+
</main>
|
|
84
|
+
|
|
85
|
+
<dialog id="question" class="question">
|
|
86
|
+
<form method="dialog" id="answer">
|
|
87
|
+
<h2>The server is asking</h2>
|
|
88
|
+
<p id="question-text"></p>
|
|
89
|
+
<div id="question-fields"></div>
|
|
90
|
+
<menu>
|
|
91
|
+
<button value="cancel" class="quiet">Cancel</button>
|
|
92
|
+
<button value="decline" class="quiet">Decline</button>
|
|
93
|
+
<button value="accept" class="primary" autofocus>Accept</button>
|
|
94
|
+
</menu>
|
|
95
|
+
</form>
|
|
96
|
+
</dialog>
|
|
97
|
+
|
|
98
|
+
<script src="{{BASE}}/console.js"></script>
|
|
99
|
+
</body>
|
|
100
|
+
</html>
|
aiohttp_tiny_mcp/core.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Protocol-independent calls, outcomes, failures, and elicitation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from .models import Implementation, Params
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Operation(str, Enum):
|
|
17
|
+
DESCRIBE = "describe"
|
|
18
|
+
HANDSHAKE_COMPLETE = "handshake_complete"
|
|
19
|
+
PING = "ping"
|
|
20
|
+
LIST_TOOLS = "list_tools"
|
|
21
|
+
CALL_TOOL = "call_tool"
|
|
22
|
+
LIST_RESOURCES = "list_resources"
|
|
23
|
+
LIST_RESOURCE_TEMPLATES = "list_resource_templates"
|
|
24
|
+
READ_RESOURCE = "read_resource"
|
|
25
|
+
LIST_PROMPTS = "list_prompts"
|
|
26
|
+
GET_PROMPT = "get_prompt"
|
|
27
|
+
COMPLETE = "complete"
|
|
28
|
+
LISTEN = "listen"
|
|
29
|
+
SUBSCRIBE = "subscribe"
|
|
30
|
+
UNSUBSCRIBE = "unsubscribe"
|
|
31
|
+
SET_LOG_LEVEL = "set_log_level"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
LOG_LEVELS: tuple[str, ...] = (
|
|
35
|
+
"debug",
|
|
36
|
+
"info",
|
|
37
|
+
"notice",
|
|
38
|
+
"warning",
|
|
39
|
+
"error",
|
|
40
|
+
"critical",
|
|
41
|
+
"alert",
|
|
42
|
+
"emergency",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def logs_at(wanted: str | None, level: str) -> bool:
|
|
47
|
+
"""Report the requested severity and above; None disables logging."""
|
|
48
|
+
if wanted is None:
|
|
49
|
+
return False
|
|
50
|
+
try:
|
|
51
|
+
return LOG_LEVELS.index(level) >= LOG_LEVELS.index(wanted)
|
|
52
|
+
except ValueError:
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ClientProfile:
|
|
58
|
+
"""Client identity, capabilities, and desired log level."""
|
|
59
|
+
|
|
60
|
+
info: Implementation
|
|
61
|
+
capabilities: Mapping[str, Any] = field(default_factory=dict)
|
|
62
|
+
log_level: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FailureKind(str, Enum):
|
|
66
|
+
PARSE = "parse"
|
|
67
|
+
MALFORMED = "malformed"
|
|
68
|
+
UNKNOWN_METHOD = "unknown_method"
|
|
69
|
+
UNKNOWN_TARGET = "unknown_target"
|
|
70
|
+
RESOURCE_NOT_FOUND = "resource_not_found"
|
|
71
|
+
INVALID_PARAMS = "invalid_params"
|
|
72
|
+
INVALID_ARGUMENTS = "invalid_arguments"
|
|
73
|
+
HEADER_MISMATCH = "header_mismatch"
|
|
74
|
+
UNSUPPORTED_VERSION = "unsupported_version"
|
|
75
|
+
ORIGIN_REJECTED = "origin_rejected"
|
|
76
|
+
INPUT_UNSUPPORTED = "input_unsupported"
|
|
77
|
+
MISSING_REQUIRED_CAPABILITY = "missing_required_capability"
|
|
78
|
+
INTERNAL = "internal"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
InputRequest = Mapping[str, Any]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(slots=True)
|
|
85
|
+
class Value:
|
|
86
|
+
result: BaseModel
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(slots=True)
|
|
90
|
+
class NeedsInput:
|
|
91
|
+
requests: Mapping[str, InputRequest]
|
|
92
|
+
state: Any = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(slots=True)
|
|
96
|
+
class Failure:
|
|
97
|
+
kind: FailureKind
|
|
98
|
+
message: str
|
|
99
|
+
data: Any = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
Outcome = Value | NeedsInput | Failure
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True)
|
|
106
|
+
class ClientInfo:
|
|
107
|
+
name: str | None = None
|
|
108
|
+
version: str | None = None
|
|
109
|
+
capabilities: Mapping[str, Any] = field(default_factory=dict)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(slots=True)
|
|
113
|
+
class Call:
|
|
114
|
+
operation: Operation
|
|
115
|
+
id: str | int | None
|
|
116
|
+
target: str | None
|
|
117
|
+
arguments: Mapping[str, Any]
|
|
118
|
+
params: Params
|
|
119
|
+
client: ClientInfo
|
|
120
|
+
progress_token: str | int | None = None
|
|
121
|
+
log_level: str | None = None
|
|
122
|
+
answers: Mapping[str, Any] = field(default_factory=dict)
|
|
123
|
+
actions: Mapping[str, AnswerAction] = field(default_factory=dict)
|
|
124
|
+
state: Any = None
|
|
125
|
+
raw: Mapping[str, Any] = field(default_factory=dict)
|
|
126
|
+
is_notification: bool = False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class DecodeFailure:
|
|
131
|
+
"""A failed batch item carried alongside valid calls without aborting the batch."""
|
|
132
|
+
|
|
133
|
+
id: str | int | None
|
|
134
|
+
failure: Failure
|
|
135
|
+
must_respond: bool = True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def decode_failure_target(body: Mapping[str, Any]) -> tuple[str | int | None, bool]:
|
|
139
|
+
"""Return the safe response id and whether an invalid message gets a reply.
|
|
140
|
+
|
|
141
|
+
A missing id suppresses a reply only when the object is structurally a
|
|
142
|
+
JSON-RPC notification. Invalid request objects still receive an error with
|
|
143
|
+
a null id. MCP request ids are strings or integers; booleans are not
|
|
144
|
+
integers for this purpose even though ``bool`` subclasses ``int`` in Python.
|
|
145
|
+
"""
|
|
146
|
+
if "id" in body:
|
|
147
|
+
value = body["id"]
|
|
148
|
+
usable = isinstance(value, str) or (isinstance(value, int) and not isinstance(value, bool))
|
|
149
|
+
return (value, True) if usable else (None, True)
|
|
150
|
+
params = body.get("params", {})
|
|
151
|
+
is_notification = (
|
|
152
|
+
body.get("jsonrpc") == "2.0"
|
|
153
|
+
and isinstance(body.get("method"), str)
|
|
154
|
+
and isinstance(params, (dict, list))
|
|
155
|
+
)
|
|
156
|
+
return None, not is_notification
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
VERSION_QUERY_PARAM = "mcp"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True, slots=True)
|
|
163
|
+
class Preamble:
|
|
164
|
+
raw: bytes
|
|
165
|
+
body: Any
|
|
166
|
+
parse_error: bool
|
|
167
|
+
headers: Mapping[str, str]
|
|
168
|
+
is_batch: bool
|
|
169
|
+
method: str | None
|
|
170
|
+
header_version: str | None
|
|
171
|
+
meta_version: str | None
|
|
172
|
+
query_version: str | None
|
|
173
|
+
initialize_version: str | None
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def of(
|
|
177
|
+
cls,
|
|
178
|
+
raw: bytes,
|
|
179
|
+
headers: Mapping[str, str],
|
|
180
|
+
query: Mapping[str, str] | None = None,
|
|
181
|
+
) -> Preamble:
|
|
182
|
+
try:
|
|
183
|
+
body = json.loads(raw)
|
|
184
|
+
parse_error = False
|
|
185
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
186
|
+
body = None
|
|
187
|
+
parse_error = True
|
|
188
|
+
method = body.get("method") if isinstance(body, dict) else None
|
|
189
|
+
params = body.get("params") if isinstance(body, dict) else None
|
|
190
|
+
meta = params.get("_meta") if isinstance(params, dict) else None
|
|
191
|
+
meta_version = (
|
|
192
|
+
meta.get("io.modelcontextprotocol/protocolVersion") if isinstance(meta, dict) else None
|
|
193
|
+
)
|
|
194
|
+
initialize_version = (
|
|
195
|
+
params.get("protocolVersion")
|
|
196
|
+
if method == "initialize" and isinstance(params, dict)
|
|
197
|
+
else None
|
|
198
|
+
)
|
|
199
|
+
return cls(
|
|
200
|
+
raw=raw,
|
|
201
|
+
body=body,
|
|
202
|
+
parse_error=parse_error,
|
|
203
|
+
headers=headers,
|
|
204
|
+
is_batch=isinstance(body, list),
|
|
205
|
+
method=method,
|
|
206
|
+
header_version=headers.get("MCP-Protocol-Version"),
|
|
207
|
+
meta_version=meta_version,
|
|
208
|
+
query_version=(query or {}).get(VERSION_QUERY_PARAM),
|
|
209
|
+
initialize_version=initialize_version,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class Rejected(Exception):
|
|
214
|
+
"""A protocol failure that aborts request handling before a result is produced."""
|
|
215
|
+
|
|
216
|
+
def __init__(self, failure: Failure) -> None:
|
|
217
|
+
super().__init__(failure.message)
|
|
218
|
+
self.failure = failure
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class NeedInput(Exception):
|
|
222
|
+
"""Raise from a tool/prompt/resource handler to trigger MRTR."""
|
|
223
|
+
|
|
224
|
+
def __init__(self, requests: Mapping[str, InputRequest], state: Any = None) -> None:
|
|
225
|
+
super().__init__("input required")
|
|
226
|
+
self.requests = requests
|
|
227
|
+
self.state = state
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def elicit(message: str, schema: dict[str, Any] | None = None) -> InputRequest:
|
|
231
|
+
"""Ask the user something. Omit `schema` to ask only for agreement."""
|
|
232
|
+
return {
|
|
233
|
+
"method": "elicitation/create",
|
|
234
|
+
"params": {
|
|
235
|
+
"message": message,
|
|
236
|
+
"requestedSchema": schema
|
|
237
|
+
if schema is not None
|
|
238
|
+
else {"type": "object", "properties": {}},
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def elicit_accept(content: Mapping[str, Any]) -> dict[str, Any]:
|
|
244
|
+
return {"action": "accept", "content": dict(content)}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def elicit_decline() -> dict[str, Any]:
|
|
248
|
+
return {"action": "decline"}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def elicit_cancel() -> dict[str, Any]:
|
|
252
|
+
return {"action": "cancel"}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class AnswerAction(str, Enum):
|
|
256
|
+
"""What a client did with one input request (`ElicitResult.action`)."""
|
|
257
|
+
|
|
258
|
+
ACCEPT = "accept"
|
|
259
|
+
DECLINE = "decline"
|
|
260
|
+
CANCEL = "cancel"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass(frozen=True, slots=True)
|
|
264
|
+
class Answer:
|
|
265
|
+
"""Unwrapped reply content and action. Empty content may still mean acceptance."""
|
|
266
|
+
|
|
267
|
+
action: AnswerAction
|
|
268
|
+
content: Mapping[str, Any] = field(default_factory=dict)
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def accepted(self) -> bool:
|
|
272
|
+
return self.action is AnswerAction.ACCEPT
|
|
273
|
+
|
|
274
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
275
|
+
return self.content.get(key, default)
|
|
276
|
+
|
|
277
|
+
def __getitem__(self, key: str) -> Any:
|
|
278
|
+
return self.content[key]
|
|
279
|
+
|
|
280
|
+
def __contains__(self, key: str) -> bool:
|
|
281
|
+
return key in self.content
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def answer_of(reply: Mapping[str, Any]) -> Answer:
|
|
285
|
+
"""Convert an ElicitResult envelope into the reply a handler sees."""
|
|
286
|
+
action = reply.get("action")
|
|
287
|
+
unwrapped = unwrap_answers({"": reply})
|
|
288
|
+
try:
|
|
289
|
+
resolved = AnswerAction(action) if isinstance(action, str) else AnswerAction.ACCEPT
|
|
290
|
+
except ValueError:
|
|
291
|
+
resolved = AnswerAction.DECLINE
|
|
292
|
+
return Answer(action=resolved, content=unwrapped.get("", {}))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def answer_actions(raw: Mapping[str, Any] | None) -> dict[str, AnswerAction]:
|
|
296
|
+
"""Extract acceptance separately from content: empty forms can still be accepted."""
|
|
297
|
+
if not raw:
|
|
298
|
+
return {}
|
|
299
|
+
actions: dict[str, AnswerAction] = {}
|
|
300
|
+
for key, value in raw.items():
|
|
301
|
+
action = value.get("action") if isinstance(value, dict) else None
|
|
302
|
+
if not isinstance(action, str):
|
|
303
|
+
# A bare answer carries content and no verdict: it is an agreement.
|
|
304
|
+
actions[key] = AnswerAction.ACCEPT
|
|
305
|
+
continue
|
|
306
|
+
try:
|
|
307
|
+
actions[key] = AnswerAction(action)
|
|
308
|
+
except ValueError:
|
|
309
|
+
# An action this revision does not define is not agreement.
|
|
310
|
+
actions[key] = AnswerAction.DECLINE
|
|
311
|
+
return actions
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def unwrap_answers(raw: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
315
|
+
"""Extract answer content from ElicitResult envelopes. Decline/cancel remain present with empty
|
|
316
|
+
content, so they count as answered.
|
|
317
|
+
"""
|
|
318
|
+
if not raw:
|
|
319
|
+
return {}
|
|
320
|
+
answers: dict[str, Any] = {}
|
|
321
|
+
for key, value in raw.items():
|
|
322
|
+
if isinstance(value, dict) and "action" in value:
|
|
323
|
+
accepted = value["action"] == "accept"
|
|
324
|
+
answers[key] = (value.get("content") or {}) if accepted else {}
|
|
325
|
+
else:
|
|
326
|
+
answers[key] = value
|
|
327
|
+
return answers
|