amkernel 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.
amkernel/server.py ADDED
@@ -0,0 +1,774 @@
1
+ """Official MCP SDK adapter for the single persistent Python Runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import json
8
+ import os
9
+ import signal
10
+ import sys
11
+ import threading
12
+ import time
13
+ from collections.abc import Awaitable, Callable, Iterator
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from types import FrameType
17
+ from typing import Annotated, Any, Literal, Protocol, TextIO, cast
18
+
19
+ import mcp.types as types
20
+ from mcp.server.context import ServerRequestContext
21
+ from mcp.server.lowlevel import Server
22
+ from mcp.server.stdio import stdio_server
23
+ from mcp.shared.exceptions import MCPError
24
+ from pydantic import (
25
+ AfterValidator,
26
+ BaseModel,
27
+ ConfigDict,
28
+ Field,
29
+ StrictBool,
30
+ StrictStr,
31
+ ValidationError,
32
+ model_validator,
33
+ )
34
+
35
+ from amkernel import __version__
36
+ from amkernel.artifact_store import (
37
+ ArtifactIntegrityError,
38
+ ArtifactNotFoundError,
39
+ ArtifactRecord,
40
+ ArtifactResource,
41
+ )
42
+ from amkernel.output import OutputObserver, OutputSnapshot
43
+ from amkernel.protocol import (
44
+ MAX_ARTIFACT_BYTES,
45
+ MAX_CODE_BYTES,
46
+ MAX_IMAGE_DIMENSION,
47
+ MAX_INLINE_IMAGE_BYTES,
48
+ MAX_INLINE_IMAGE_ITEM_BYTES,
49
+ MAX_LIVE_MESSAGE_BYTES,
50
+ MAX_LOGICAL_NAME_CODEPOINTS,
51
+ MAX_MIME_BYTES,
52
+ MAX_OUTPUT_SEQUENCE,
53
+ MAX_PACKAGE_COUNT,
54
+ MAX_PACKAGE_NAME_BYTES,
55
+ MAX_STORED_ITEMS,
56
+ MAX_STRUCTURED_RESULT_BYTES,
57
+ validate_package_names,
58
+ )
59
+ from amkernel.runtime import KernelRuntime
60
+ from amkernel.supervisor import ShutdownResult, SupervisedExecution
61
+
62
+ TOOL_NAME = "python_exec"
63
+ TOOL_TITLE = "Execute Python"
64
+ DEFAULT_TIMEOUT = 30.0
65
+ MAX_TITLE_CODEPOINTS = 200
66
+ LiveStatus = Literal["unavailable", "progress", "failed"]
67
+
68
+ TOOL_DESCRIPTION = (
69
+ "Execute one Python cell in a persistent local Runtime.\n\n"
70
+ "Calls serialize in FIFO order and share imports, variables, functions, mutations, and live "
71
+ "objects until reset. Mutations completed before a Python exception remain. Set reset=true to "
72
+ "destructively replace the kernel before this cell; reset and shutdown clean only AMKernel's "
73
+ "managed process group, not escaped processes, sockets, or external sessions. input() is "
74
+ "unsupported. Always inspect status and kernelState.\n\n"
75
+ "Artifact resource URIs are ephemeral: capacity pressure or server shutdown can expire them. "
76
+ "Read and copy every manifest part before making another large-output call. Artifact bytes are "
77
+ "untrusted; random identifiers are not same-user access control.\n\n"
78
+ "Code runs unrestricted with the permissions of the user who launched AMKernel. This is not a "
79
+ "sandbox and execution is not workspace-confined."
80
+ )
81
+
82
+
83
+ def _validate_code_bytes(value: str) -> str:
84
+ if len(value.encode("utf-8")) > MAX_CODE_BYTES:
85
+ raise ValueError(f"code exceeds {MAX_CODE_BYTES} UTF-8 bytes")
86
+ return value
87
+
88
+
89
+ Code = Annotated[
90
+ StrictStr,
91
+ Field(max_length=MAX_CODE_BYTES, description="One Python cell; empty cells are valid."),
92
+ AfterValidator(_validate_code_bytes),
93
+ ]
94
+
95
+
96
+ def _validate_packages(value: list[str]) -> list[str]:
97
+ return list(validate_package_names(value))
98
+
99
+
100
+ PackageName = Annotated[
101
+ StrictStr,
102
+ Field(
103
+ min_length=1,
104
+ max_length=MAX_PACKAGE_NAME_BYTES,
105
+ pattern=r"^[A-Za-z0-9]+(?:[._-]+[A-Za-z0-9]+)*$",
106
+ ),
107
+ ]
108
+ Packages = Annotated[
109
+ list[PackageName],
110
+ Field(max_length=MAX_PACKAGE_COUNT),
111
+ AfterValidator(_validate_packages),
112
+ ]
113
+ Title = Annotated[
114
+ StrictStr,
115
+ Field(max_length=MAX_TITLE_CODEPOINTS, description="Presentation metadata only."),
116
+ ]
117
+ Timeout = Annotated[
118
+ float,
119
+ Field(strict=True, gt=0, le=3_600, allow_inf_nan=False, description="Active-cell seconds."),
120
+ ]
121
+
122
+
123
+ class _StrictModel(BaseModel):
124
+ model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
125
+
126
+
127
+ class PythonExecInput(_StrictModel):
128
+ code: Code
129
+ title: Title = ""
130
+ timeout: Timeout = DEFAULT_TIMEOUT
131
+ reset: StrictBool = False
132
+ packages: Packages = Field(default_factory=list)
133
+
134
+
135
+ class ExecutionError(_StrictModel):
136
+ type: str
137
+ message: str
138
+ traceback: list[str]
139
+
140
+
141
+ class ArtifactRef(_StrictModel):
142
+ uri: str
143
+ name: str = Field(min_length=1, max_length=MAX_LOGICAL_NAME_CODEPOINTS)
144
+ declared_mime_type: str = Field(
145
+ min_length=1, max_length=MAX_MIME_BYTES, alias="declaredMimeType"
146
+ )
147
+ mime_type: str = Field(min_length=1, max_length=MAX_MIME_BYTES, alias="mimeType")
148
+ representation: Literal["raw", "manifest"]
149
+ size: int = Field(ge=0, le=MAX_ARTIFACT_BYTES)
150
+ sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
151
+
152
+
153
+ class PackageInfo(_StrictModel):
154
+ name: PackageName
155
+ version: str = cast(str, None)
156
+ error: Literal["not installed", "metadata unavailable", "invalid metadata"] = cast(
157
+ Literal["not installed", "metadata unavailable", "invalid metadata"], None
158
+ )
159
+
160
+ @model_validator(mode="after")
161
+ def _require_one_result(self) -> PackageInfo:
162
+ if (self.version is None) == (self.error is None):
163
+ raise ValueError("package requires exactly one of version or error")
164
+ return self
165
+
166
+
167
+ class RuntimeInfo(_StrictModel):
168
+ executable: str
169
+ python_version: str = Field(alias="pythonVersion")
170
+ working_directory: str = Field(alias="workingDirectory")
171
+ environment_kind: Literal["project_venv", "active_venv", "system"] = Field(
172
+ alias="environmentKind"
173
+ )
174
+ implementation: str
175
+ platform: str
176
+ packages: Annotated[list[PackageInfo], Field(max_length=MAX_PACKAGE_COUNT)]
177
+
178
+
179
+ class JsonDisplay(_StrictModel):
180
+ sequence: int = Field(ge=1, le=MAX_OUTPUT_SEQUENCE)
181
+ kind: Literal["json"]
182
+ value: Any
183
+
184
+
185
+ class ImageDisplay(_StrictModel):
186
+ sequence: int = Field(ge=1, le=MAX_OUTPUT_SEQUENCE)
187
+ kind: Literal["image"]
188
+ mime_type: Literal["image/png", "image/jpeg", "image/gif", "image/webp"] = Field(
189
+ alias="mimeType"
190
+ )
191
+ size: int = Field(ge=0, le=MAX_ARTIFACT_BYTES)
192
+ sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
193
+ width: int = Field(ge=1, le=MAX_IMAGE_DIMENSION)
194
+ height: int = Field(ge=1, le=MAX_IMAGE_DIMENSION)
195
+ inline: bool
196
+ artifact: ArtifactRef
197
+
198
+
199
+ class ArtifactDisplay(_StrictModel):
200
+ sequence: int = Field(ge=1, le=MAX_OUTPUT_SEQUENCE)
201
+ kind: Literal["artifact"]
202
+ artifact: ArtifactRef
203
+
204
+
205
+ DisplayItem = Annotated[JsonDisplay | ImageDisplay | ArtifactDisplay, Field(discriminator="kind")]
206
+
207
+
208
+ class OutputInfo(_StrictModel):
209
+ total_bytes: int = Field(ge=0, alias="totalBytes")
210
+ total_lines: int = Field(ge=0, alias="totalLines")
211
+ discarded_bytes: int = Field(ge=0, alias="discardedBytes")
212
+ discarded_lines: int = Field(ge=0, alias="discardedLines")
213
+ truncated: bool
214
+ limit_bytes: int = Field(ge=1, alias="limitBytes")
215
+ limit_exceeded: bool = Field(alias="limitExceeded")
216
+ live: Literal["progress", "failed", "unavailable"]
217
+ stdout_artifact: ArtifactRef = Field(default=cast(ArtifactRef, None), alias="stdoutArtifact")
218
+ stderr_artifact: ArtifactRef = Field(default=cast(ArtifactRef, None), alias="stderrArtifact")
219
+
220
+
221
+ class PythonExecResult(_StrictModel):
222
+ status: Literal["completed", "failed", "timed_out", "kernel_killed"]
223
+ stdout: str
224
+ stderr: str
225
+ result: Any = None
226
+ error: ExecutionError = cast(ExecutionError, None)
227
+ execution_count: int = Field(ge=0, alias="executionCount")
228
+ duration_ms: int = Field(ge=0, alias="durationMs")
229
+ kernel_state: Literal["alive", "dead"] = Field(alias="kernelState")
230
+ runtime: RuntimeInfo
231
+ output: OutputInfo
232
+ displays: Annotated[list[DisplayItem], Field(max_length=MAX_OUTPUT_SEQUENCE)]
233
+ artifacts: Annotated[list[ArtifactRef], Field(max_length=MAX_STORED_ITEMS)]
234
+
235
+
236
+ INPUT_SCHEMA: dict[str, Any] = PythonExecInput.model_json_schema(by_alias=True)
237
+ OUTPUT_SCHEMA: dict[str, Any] = PythonExecResult.model_json_schema(by_alias=True)
238
+
239
+
240
+ class RuntimeAdapter(Protocol):
241
+ @property
242
+ def diagnostic_tail(self) -> str: ...
243
+
244
+ async def execute(
245
+ self,
246
+ code: str,
247
+ *,
248
+ timeout: float = DEFAULT_TIMEOUT,
249
+ reset: bool = False,
250
+ packages: tuple[str, ...] = (),
251
+ observer: OutputObserver | None = None,
252
+ result_finalizer: Callable[[SupervisedExecution], Awaitable[None]] | None = None,
253
+ ) -> SupervisedExecution: ...
254
+
255
+ async def read_resource(self, uri: str) -> ArtifactResource: ...
256
+
257
+ async def shutdown(self) -> ShutdownResult: ...
258
+
259
+
260
+ class _DiagnosticEmitter:
261
+ def __init__(self, stream: TextIO) -> None:
262
+ self._stream = stream
263
+ self._previous = ""
264
+
265
+ def emit(self, current: str) -> None:
266
+ detail = current[len(self._previous) :] if current.startswith(self._previous) else current
267
+ self._previous = current
268
+ self.write(detail)
269
+
270
+ def write(self, detail: str) -> None:
271
+ if not detail:
272
+ return
273
+ try:
274
+ self._stream.write(detail)
275
+ if not detail.endswith("\n"):
276
+ self._stream.write("\n")
277
+ self._stream.flush()
278
+ except (OSError, ValueError):
279
+ pass
280
+
281
+ def emit_cleanup_failure(self, result: ShutdownResult) -> None:
282
+ if result.cleaned:
283
+ return
284
+ detail = result.error or "managed kernel cleanup failed"
285
+ self.write(f"AMKernel shutdown cleanup failed: {detail}\n")
286
+
287
+
288
+ def _structured_result(
289
+ outcome: SupervisedExecution,
290
+ *,
291
+ live: Literal["unavailable", "progress", "failed"] = "unavailable",
292
+ ) -> dict[str, Any]:
293
+ fields: dict[str, Any] = {
294
+ "status": outcome.status,
295
+ "stdout": outcome.stdout,
296
+ "stderr": outcome.stderr,
297
+ "execution_count": outcome.execution_count,
298
+ "duration_ms": outcome.duration_ms,
299
+ "kernel_state": outcome.kernel_state,
300
+ "runtime": RuntimeInfo(
301
+ executable=outcome.runtime.executable,
302
+ pythonVersion=outcome.runtime.python_version,
303
+ workingDirectory=outcome.runtime.working_directory,
304
+ environmentKind=outcome.runtime.environment_kind,
305
+ implementation=outcome.runtime.implementation,
306
+ platform=outcome.runtime.platform,
307
+ packages=[
308
+ PackageInfo(
309
+ name=package.name,
310
+ **(
311
+ {"version": package.version}
312
+ if package.version is not None
313
+ else {"error": package.error}
314
+ ),
315
+ )
316
+ for package in outcome.runtime.packages
317
+ ],
318
+ ),
319
+ "output": OutputInfo(
320
+ totalBytes=outcome.output.total_bytes,
321
+ totalLines=outcome.output.total_lines,
322
+ discardedBytes=outcome.output.discarded_bytes,
323
+ discardedLines=outcome.output.discarded_lines,
324
+ truncated=outcome.output.truncated,
325
+ limitBytes=outcome.output.limit_bytes,
326
+ limitExceeded=outcome.output.limit_exceeded,
327
+ live=live,
328
+ **(
329
+ {"stdoutArtifact": _artifact_ref(outcome.output.stdout_artifact)}
330
+ if outcome.output.stdout_artifact is not None
331
+ else {}
332
+ ),
333
+ **(
334
+ {"stderrArtifact": _artifact_ref(outcome.output.stderr_artifact)}
335
+ if outcome.output.stderr_artifact is not None
336
+ else {}
337
+ ),
338
+ ),
339
+ "displays": _display_items(outcome),
340
+ "artifacts": [_artifact_ref(artifact) for artifact in outcome.output.artifacts],
341
+ }
342
+ if outcome.has_result:
343
+ fields["result"] = outcome.result
344
+ if outcome.error is not None:
345
+ fields["error"] = ExecutionError(
346
+ type=outcome.error.type,
347
+ message=outcome.error.message,
348
+ traceback=list(outcome.error.traceback),
349
+ )
350
+ validated = PythonExecResult.model_validate(fields)
351
+ return validated.model_dump(mode="json", by_alias=True, exclude_unset=True)
352
+
353
+
354
+ def _display_items(outcome: SupervisedExecution) -> list[dict[str, Any]]:
355
+ fields: list[dict[str, Any]] = []
356
+ inline_bytes = 0
357
+ for display in outcome.output.displays:
358
+ if display.kind == "json":
359
+ fields.append({"sequence": display.sequence, "kind": "json", "value": display.value})
360
+ continue
361
+ artifact = display.artifact
362
+ if artifact is None:
363
+ raise ValueError("stored display is missing artifact metadata")
364
+ if display.kind == "artifact":
365
+ fields.append(
366
+ {
367
+ "sequence": display.sequence,
368
+ "kind": "artifact",
369
+ "artifact": _artifact_ref(artifact),
370
+ }
371
+ )
372
+ continue
373
+ image = display.image
374
+ if image is None:
375
+ raise ValueError("stored image display is missing decoded metadata")
376
+ inline = (
377
+ artifact.size <= MAX_INLINE_IMAGE_ITEM_BYTES
378
+ and inline_bytes + artifact.size <= MAX_INLINE_IMAGE_BYTES
379
+ )
380
+ if inline:
381
+ inline_bytes += artifact.size
382
+ fields.append(
383
+ {
384
+ "sequence": display.sequence,
385
+ "kind": "image",
386
+ "mimeType": artifact.declared_mime_type,
387
+ "size": artifact.size,
388
+ "sha256": artifact.sha256,
389
+ "width": image.width,
390
+ "height": image.height,
391
+ "inline": inline,
392
+ "artifact": _artifact_ref(artifact),
393
+ }
394
+ )
395
+ return fields
396
+
397
+
398
+ def _artifact_ref(record: ArtifactRecord) -> ArtifactRef:
399
+ return ArtifactRef(
400
+ uri=record.uri,
401
+ name=record.name,
402
+ declaredMimeType=record.declared_mime_type,
403
+ mimeType=record.mime_type,
404
+ representation=record.representation,
405
+ size=record.size,
406
+ sha256=record.sha256,
407
+ )
408
+
409
+
410
+ class _ProgressReporter:
411
+ def __init__(
412
+ self,
413
+ context: ServerRequestContext[dict[str, Any]],
414
+ diagnostics: _DiagnosticEmitter,
415
+ ) -> None:
416
+ self.observer = OutputObserver()
417
+ self.status: Literal["progress", "failed"] = "progress"
418
+ self._context = context
419
+ self._diagnostics = diagnostics
420
+
421
+ async def run(self) -> None:
422
+ last_send = 0.0
423
+ while True:
424
+ snapshot = await self.observer.next()
425
+ delay = 0.1 - (time.monotonic() - last_send)
426
+ if delay > 0:
427
+ await asyncio.sleep(delay)
428
+ snapshot = self.observer.drain_latest(snapshot)
429
+ try:
430
+ await self._context.session.report_progress(
431
+ float(snapshot.revision),
432
+ None,
433
+ _progress_message(snapshot),
434
+ )
435
+ except Exception as error:
436
+ self.status = "failed"
437
+ self._diagnostics.write(
438
+ f"python_exec progress reporter failed: {type(error).__name__}\n"
439
+ )
440
+ return
441
+ last_send = time.monotonic()
442
+
443
+ async def close(self, task: asyncio.Task[None]) -> None:
444
+ if not task.done():
445
+ task.cancel()
446
+ await asyncio.gather(task, return_exceptions=True)
447
+
448
+
449
+ def _progress_message(snapshot: OutputSnapshot) -> str:
450
+ prefix = f"{snapshot.channel}: "
451
+ remaining = MAX_LIVE_MESSAGE_BYTES - len(prefix.encode("utf-8"))
452
+ payload = snapshot.text.encode("utf-8")[-remaining:]
453
+ while payload and payload[0] & 0xC0 == 0x80:
454
+ payload = payload[1:]
455
+ return prefix + payload.decode("utf-8")
456
+
457
+
458
+ def _progress_reporter(
459
+ context: ServerRequestContext[dict[str, Any]],
460
+ diagnostics: _DiagnosticEmitter,
461
+ ) -> _ProgressReporter | None:
462
+ meta = context.meta
463
+ if meta is None or (meta.get("progressToken") is None and meta.get("progress_token") is None):
464
+ return None
465
+ return _ProgressReporter(context, diagnostics)
466
+
467
+
468
+ async def _tool_content(
469
+ runtime: RuntimeAdapter,
470
+ outcome: SupervisedExecution,
471
+ structured: dict[str, Any],
472
+ canonical_json: str,
473
+ ) -> list[types.ContentBlock]:
474
+ content: list[types.ContentBlock] = [types.TextContent(type="text", text=canonical_json)]
475
+ displays = {
476
+ item["sequence"]: item for item in cast(list[dict[str, Any]], structured["displays"])
477
+ }
478
+ for display in outcome.output.displays:
479
+ if display.kind == "json":
480
+ continue
481
+ artifact = display.artifact
482
+ if artifact is None:
483
+ raise ValueError("stored display is missing artifact metadata")
484
+ if display.kind == "image" and displays[display.sequence]["inline"]:
485
+ resource = await runtime.read_resource(artifact.uri)
486
+ if resource.text:
487
+ raise ValueError("inline image resource is not binary")
488
+ content.append(
489
+ types.ImageContent(
490
+ type="image",
491
+ data=base64.b64encode(resource.data).decode("ascii"),
492
+ mime_type=artifact.mime_type,
493
+ )
494
+ )
495
+ content.append(_resource_link(artifact))
496
+
497
+ for artifact in (outcome.output.stdout_artifact, outcome.output.stderr_artifact):
498
+ if artifact is not None:
499
+ content.append(_resource_link(artifact))
500
+ return content
501
+
502
+
503
+ def _resource_link(record: ArtifactRecord) -> types.ResourceLink:
504
+ artifact_id = record.uri.rsplit("/", 1)[-1]
505
+ return types.ResourceLink(
506
+ type="resource_link",
507
+ uri=record.uri,
508
+ name=f"artifact-{artifact_id}",
509
+ title=record.name,
510
+ mime_type=record.mime_type,
511
+ size=record.size,
512
+ )
513
+
514
+
515
+ async def _map_tool_result(
516
+ runtime: RuntimeAdapter,
517
+ outcome: SupervisedExecution,
518
+ *,
519
+ live: LiveStatus,
520
+ ) -> types.CallToolResult:
521
+ structured = _structured_result(outcome, live=live)
522
+ text = json.dumps(
523
+ structured,
524
+ ensure_ascii=False,
525
+ allow_nan=False,
526
+ separators=(",", ":"),
527
+ )
528
+ if len(text.encode("utf-8")) > MAX_STRUCTURED_RESULT_BYTES:
529
+ raise ValueError("structured result exceeds terminal byte bound")
530
+ content = await _tool_content(runtime, outcome, structured, text)
531
+ return types.CallToolResult(
532
+ content=content,
533
+ structured_content=structured,
534
+ is_error=False,
535
+ )
536
+
537
+
538
+ def _minimal_tool_result(
539
+ outcome: SupervisedExecution,
540
+ *,
541
+ live: LiveStatus,
542
+ ) -> types.CallToolResult:
543
+ structured = _structured_result(outcome, live=live)
544
+ text = json.dumps(
545
+ structured,
546
+ ensure_ascii=False,
547
+ allow_nan=False,
548
+ separators=(",", ":"),
549
+ )
550
+ return types.CallToolResult(
551
+ content=[types.TextContent(type="text", text=text)],
552
+ structured_content=structured,
553
+ is_error=False,
554
+ )
555
+
556
+
557
+ def _tool_error(message: str) -> types.CallToolResult:
558
+ return types.CallToolResult(
559
+ content=[types.TextContent(type="text", text=message)],
560
+ is_error=True,
561
+ )
562
+
563
+
564
+ def create_server(
565
+ runtime: RuntimeAdapter,
566
+ *,
567
+ diagnostic_stream: TextIO | None = None,
568
+ ) -> Server[dict[str, Any]]:
569
+ """Bind one Runtime to the sole model-facing MCP tool."""
570
+ return _create_server(runtime, _DiagnosticEmitter(diagnostic_stream or sys.stderr))
571
+
572
+
573
+ def _create_server(
574
+ runtime: RuntimeAdapter,
575
+ diagnostics: _DiagnosticEmitter,
576
+ ) -> Server[dict[str, Any]]:
577
+ async def list_tools(
578
+ _context: ServerRequestContext[dict[str, Any]],
579
+ _params: types.PaginatedRequestParams | None,
580
+ ) -> types.ListToolsResult:
581
+ return types.ListToolsResult(
582
+ tools=[
583
+ types.Tool(
584
+ name=TOOL_NAME,
585
+ title=TOOL_TITLE,
586
+ description=TOOL_DESCRIPTION,
587
+ input_schema=INPUT_SCHEMA,
588
+ output_schema=OUTPUT_SCHEMA,
589
+ )
590
+ ]
591
+ )
592
+
593
+ async def list_resources(
594
+ _context: ServerRequestContext[dict[str, Any]],
595
+ _params: types.PaginatedRequestParams | None,
596
+ ) -> types.ListResourcesResult:
597
+ return types.ListResourcesResult(
598
+ resources=[],
599
+ cache_scope="private",
600
+ result_type="complete",
601
+ ttl_ms=0,
602
+ )
603
+
604
+ async def read_resource(
605
+ _context: ServerRequestContext[dict[str, Any]],
606
+ params: types.ReadResourceRequestParams,
607
+ ) -> types.ReadResourceResult:
608
+ try:
609
+ resource = await runtime.read_resource(str(params.uri))
610
+ except ArtifactNotFoundError as error:
611
+ raise MCPError(types.INVALID_PARAMS, "resource not found") from error
612
+ except ArtifactIntegrityError as error:
613
+ diagnostics.emit(runtime.diagnostic_tail)
614
+ raise MCPError(types.INTERNAL_ERROR, "resource integrity failure") from error
615
+ if resource.text:
616
+ content: types.TextResourceContents | types.BlobResourceContents = (
617
+ types.TextResourceContents(
618
+ uri=resource.uri,
619
+ mime_type=resource.mime_type,
620
+ text=resource.data.decode("utf-8", errors="strict"),
621
+ )
622
+ )
623
+ else:
624
+ content = types.BlobResourceContents(
625
+ uri=resource.uri,
626
+ mime_type=resource.mime_type,
627
+ blob=base64.b64encode(resource.data).decode("ascii"),
628
+ )
629
+ return types.ReadResourceResult(
630
+ contents=[content],
631
+ cache_scope="private",
632
+ result_type="complete",
633
+ ttl_ms=0,
634
+ )
635
+
636
+ async def call_tool(
637
+ _context: ServerRequestContext[dict[str, Any]],
638
+ params: types.CallToolRequestParams,
639
+ ) -> types.CallToolResult:
640
+ if params.name != TOOL_NAME:
641
+ return _tool_error(f"Unknown tool: {params.name}")
642
+ try:
643
+ request = PythonExecInput.model_validate(params.arguments or {})
644
+ except ValidationError:
645
+ diagnostics.write("python_exec rejected invalid arguments\n")
646
+ return _tool_error("Invalid python_exec arguments")
647
+
648
+ reporter = _progress_reporter(_context, diagnostics)
649
+ reporter_task = asyncio.create_task(reporter.run()) if reporter is not None else None
650
+ mapped: types.CallToolResult | None = None
651
+
652
+ async def finalize(outcome: SupervisedExecution) -> None:
653
+ nonlocal mapped
654
+ if reporter is not None and reporter_task is not None:
655
+ await reporter.close(reporter_task)
656
+ mapped = await _map_tool_result(
657
+ runtime,
658
+ outcome,
659
+ live=reporter.status if reporter is not None else "unavailable",
660
+ )
661
+
662
+ try:
663
+ if reporter is None:
664
+ outcome = await runtime.execute(
665
+ request.code,
666
+ timeout=request.timeout,
667
+ reset=request.reset,
668
+ packages=tuple(request.packages),
669
+ result_finalizer=finalize,
670
+ )
671
+ else:
672
+ outcome = await runtime.execute(
673
+ request.code,
674
+ timeout=request.timeout,
675
+ reset=request.reset,
676
+ packages=tuple(request.packages),
677
+ observer=reporter.observer,
678
+ result_finalizer=finalize,
679
+ )
680
+ if mapped is None:
681
+ if reporter is not None and reporter_task is not None:
682
+ await reporter.close(reporter_task)
683
+ mapped = _minimal_tool_result(
684
+ outcome,
685
+ live=reporter.status if reporter is not None else "unavailable",
686
+ )
687
+ except Exception as error:
688
+ diagnostics.write(f"python_exec adapter failure: {type(error).__name__}\n")
689
+ return _tool_error("python_exec failed internally")
690
+ finally:
691
+ if reporter is not None and reporter_task is not None:
692
+ await reporter.close(reporter_task)
693
+ diagnostics.emit(runtime.diagnostic_tail)
694
+
695
+ return mapped
696
+
697
+ return Server(
698
+ "amkernel",
699
+ title="AMKernel",
700
+ description="Persistent Python execution for agentic automation.",
701
+ version=__version__,
702
+ get_tool_input_schema=lambda name: INPUT_SCHEMA if name == TOOL_NAME else None,
703
+ on_list_tools=list_tools,
704
+ on_call_tool=call_tool,
705
+ on_list_resources=list_resources,
706
+ on_read_resource=read_resource,
707
+ )
708
+
709
+
710
+ @contextmanager
711
+ def _defer_sigterm_until_cleanup() -> Iterator[Callable[[asyncio.Task[None]], None]]:
712
+ if os.name != "posix" or threading.current_thread() is not threading.main_thread():
713
+ yield lambda _task: None
714
+ return
715
+
716
+ task: asyncio.Task[None] | None = None
717
+ received = False
718
+ received_frame: FrameType | None = None
719
+
720
+ def handle_sigterm(_signal_number: int, frame: FrameType | None) -> None:
721
+ nonlocal received, received_frame
722
+ received = True
723
+ received_frame = frame
724
+ if task is not None and not task.done():
725
+ task.cancel()
726
+
727
+ def attach(current: asyncio.Task[None]) -> None:
728
+ nonlocal task
729
+ task = current
730
+ if received and not current.done():
731
+ current.cancel()
732
+
733
+ previous = signal.signal(signal.SIGTERM, handle_sigterm)
734
+ try:
735
+ yield attach
736
+ finally:
737
+ if signal.getsignal(signal.SIGTERM) is handle_sigterm:
738
+ signal.signal(signal.SIGTERM, previous)
739
+ if received:
740
+ if previous == signal.SIG_DFL:
741
+ os.kill(os.getpid(), signal.SIGTERM)
742
+ elif previous != signal.SIG_IGN and callable(previous):
743
+ previous(signal.SIGTERM, received_frame)
744
+
745
+
746
+ async def _run_server_stdio(server: Server[dict[str, Any]]) -> None:
747
+ async with stdio_server() as (read_stream, write_stream):
748
+ await server.run(
749
+ read_stream,
750
+ write_stream,
751
+ server.create_initialization_options(),
752
+ )
753
+
754
+
755
+ async def serve_stdio(
756
+ working_directory: Path | None = None,
757
+ *,
758
+ diagnostic_stream: TextIO | None = None,
759
+ runtime_factory: Callable[[Path | None], RuntimeAdapter] = KernelRuntime,
760
+ ) -> int:
761
+ """Serve stdio until transport close, then wait for managed cleanup."""
762
+ diagnostics = _DiagnosticEmitter(diagnostic_stream or sys.stderr)
763
+ runtime = runtime_factory(working_directory)
764
+ server = _create_server(runtime, diagnostics)
765
+ with _defer_sigterm_until_cleanup() as attach:
766
+ serving = asyncio.create_task(_run_server_stdio(server))
767
+ attach(serving)
768
+ try:
769
+ await serving
770
+ finally:
771
+ shutdown = await runtime.shutdown()
772
+ diagnostics.emit(runtime.diagnostic_tail)
773
+ diagnostics.emit_cleanup_failure(shutdown)
774
+ return 0 if shutdown.cleaned else 1