mainsequence 9.0.15.dev57__py3-none-any.whl → 9.0.16.dev59__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.
@@ -211,6 +211,16 @@ what people are told:
211
211
  See also
212
212
  [Requester-bound calls](https://mainsequence-sdk.github.io/mainsequence-sdk/knowledge/fastapi/#requester-bound-calls).
213
213
 
214
+ ### MCP tools
215
+
216
+ To serve MCP tools from the same application, add the `mcp` extra and call
217
+ `install_mcp(app, mcp.streamable_http_app(), lifespan=mcp.session_manager.run)`
218
+ after `install_request_identity(app)`, with a stateless FastMCP server that
219
+ returns JSON. Tools authorize with `User.get_logged_user()` and
220
+ `User.get_requester()` exactly like REST handlers; admission to the release
221
+ does not authorize every tool. See
222
+ [Serving MCP](https://mainsequence-sdk.github.io/mainsequence-sdk/knowledge/fastapi/#serving-mcp).
223
+
214
224
  ### Public provider callbacks and webhooks
215
225
 
216
226
  An external OAuth redirect or webhook cannot supply a Main Sequence Bearer token.
@@ -822,6 +822,34 @@ class ResourceReleaseRuntimeAccess(BasePydanticModel):
822
822
  access: dict[str, Any] | None = Field(default=None, repr=False)
823
823
 
824
824
 
825
+ class ResourceReleaseMcpConnection(BasePydanticModel):
826
+ """How to connect to the MCP endpoint of a FastAPI release's active revision."""
827
+
828
+ url: str = Field(
829
+ ...,
830
+ description="Public MCP endpoint: the release URL plus `/mcp`.",
831
+ examples=["https://prices.example.test/mcp"],
832
+ )
833
+ transport: OpenValueSet[Literal["streamable-http"]] = Field(
834
+ ...,
835
+ description=(
836
+ "MCP transport of the endpoint. A value this SDK does not declare is kept "
837
+ "but is not a transport to connect with."
838
+ ),
839
+ examples=["streamable-http"],
840
+ )
841
+ resource_metadata_url: str | None = Field(
842
+ None,
843
+ description="OAuth protected-resource metadata URL the endpoint's challenge points to.",
844
+ )
845
+ organization_environment_uid: str | None = Field(
846
+ None, description="Public UID of the connection's Organization Environment."
847
+ )
848
+ active_revision_uid: str | None = Field(
849
+ None, description="Public UID of the revision that advertises the endpoint."
850
+ )
851
+
852
+
825
853
  class ResourceRelease(
826
854
  CurrentCodeRepositoryBranchCollectionMixin,
827
855
  EnvironmentLogSearchMixin,
@@ -853,6 +881,7 @@ class ResourceRelease(
853
881
  "resource__uid": ["exact", "in"],
854
882
  "related_job__uid": ["exact", "in"],
855
883
  "release_kind": ["exact", "in"],
884
+ "mcp_available": ["exact"],
856
885
  }
857
886
  FILTER_VALUE_NORMALIZERS: ClassVar[dict[str, str]] = {
858
887
  "uid": "uid",
@@ -861,6 +890,7 @@ class ResourceRelease(
861
890
  "resource__uid": "uid",
862
891
  "related_job__uid": "uid",
863
892
  "release_kind": "str",
893
+ "mcp_available": "bool",
864
894
  }
865
895
 
866
896
  @classmethod
@@ -1005,6 +1035,23 @@ class ResourceRelease(
1005
1035
  ),
1006
1036
  examples=["88888888-8888-4888-8888-888888888888"],
1007
1037
  )
1038
+ mcp_enabled: bool = Field(
1039
+ False,
1040
+ title="MCP Enabled",
1041
+ description=(
1042
+ "Desired MCP capability of a FastAPI release, applied by its next successful "
1043
+ "deployment. False when the response does not carry it."
1044
+ ),
1045
+ examples=[True],
1046
+ )
1047
+ mcp_connection: ResourceReleaseMcpConnection | None = Field(
1048
+ None,
1049
+ title="MCP Connection",
1050
+ description=(
1051
+ "MCP endpoint of the active MCP-enabled revision; None when no active revision "
1052
+ "advertises one. An advertised capability, not a health check."
1053
+ ),
1054
+ )
1008
1055
  observability: ObservabilityLinks | None = Field(
1009
1056
  default=None,
1010
1057
  description="Backend-owned runtime observability and related-history capabilities.",
@@ -6,12 +6,18 @@ import asyncio
6
6
  import json
7
7
  import os
8
8
  import re
9
- from contextlib import contextmanager
9
+ from contextlib import asynccontextmanager, contextmanager
10
+ from urllib.parse import urlsplit
10
11
  from uuid import UUID
11
12
 
12
13
  import requests
13
14
 
14
- from mainsequence._request_identity import _reads_as_caller, _request_scope
15
+ from mainsequence._request_identity import (
16
+ RequestIdentityError,
17
+ _get_request_identity,
18
+ _reads_as_caller,
19
+ _request_scope,
20
+ )
15
21
  from mainsequence.client.models_user import RequestUserIdentity
16
22
 
17
23
  from .caller_assertions import (
@@ -296,4 +302,154 @@ def reads_as_caller():
296
302
  yield
297
303
 
298
304
 
299
- __all__ = ["install_request_identity", "reads_as_caller"]
305
+ _MCP_INSTALLATION = "mainsequence_mcp"
306
+ _MCP_PATH = "/mcp"
307
+ _WILDCARD_ORIGIN = re.compile(r"(https?)://\*\.([a-z0-9.-]+)(?::([0-9]{1,5}))?", flags=re.ASCII)
308
+
309
+
310
+ def _mcp_allowed_origins():
311
+ """The release's public origin and its CORS origins, read once at installation."""
312
+ entries = [os.environ.get("FASTAPI_PUBLIC_BASE_URL", "")]
313
+ entries += os.environ.get("FASTAPI_CORS_ALLOW_ORIGINS", "").split(",")
314
+ exact, patterns = set(), []
315
+ for entry in entries:
316
+ entry = entry.strip().rstrip("/").lower()
317
+ wildcard = _WILDCARD_ORIGIN.fullmatch(entry)
318
+ if wildcard:
319
+ scheme, host, port = wildcard.groups()
320
+ pattern = rf"{scheme}://[a-z0-9-]+\.{re.escape(host)}"
321
+ patterns.append(re.compile(pattern + (rf":{port}" if port else ""), flags=re.ASCII))
322
+ continue
323
+ parts = urlsplit(entry)
324
+ if parts.scheme in {"http", "https"} and parts.netloc and "*" not in parts.netloc:
325
+ exact.add(f"{parts.scheme}://{parts.netloc}")
326
+ return frozenset(exact), tuple(patterns)
327
+
328
+
329
+ def _mcp_conflicts(routes, *, installed=None):
330
+ for route in routes:
331
+ path = getattr(route, "path", None)
332
+ if route is not installed and isinstance(path, str):
333
+ if path == _MCP_PATH or path.startswith(_MCP_PATH + "/"):
334
+ raise RuntimeError(f"Route {path} conflicts with the MCP endpoint at {_MCP_PATH}.")
335
+
336
+
337
+ async def _reply(send, status, detail, headers=()):
338
+ await send(
339
+ {
340
+ "type": "http.response.start",
341
+ "status": status,
342
+ "headers": [
343
+ (b"content-type", b"application/json"),
344
+ (b"cache-control", b"no-store"),
345
+ *headers,
346
+ ],
347
+ }
348
+ )
349
+ await send({"type": "http.response.body", "body": json.dumps({"detail": detail}).encode()})
350
+
351
+
352
+ class _McpEndpoint:
353
+ """Admit authenticated POSTs from an allowed Origin to the application's MCP app."""
354
+
355
+ def __init__(self, app, *, allowed_origins):
356
+ self.app = app
357
+ self.exact_origins, self.origin_patterns = allowed_origins
358
+
359
+ def _origin_allowed(self, origin):
360
+ origin = origin.strip().rstrip("/").lower()
361
+ return origin in self.exact_origins or any(
362
+ pattern.fullmatch(origin) for pattern in self.origin_patterns
363
+ )
364
+
365
+ async def __call__(self, scope, receive, send):
366
+ if scope.get("method") != "POST":
367
+ # Stateless profile: no standalone GET stream and no session to delete.
368
+ await _reply(send, 405, "Method Not Allowed.", headers=[(b"allow", b"POST")])
369
+ return
370
+ origins = [value for key, value in scope.get("headers", ()) if key.lower() == b"origin"]
371
+ if len(origins) > 1 or (origins and not self._origin_allowed(origins[0].decode("latin-1"))):
372
+ await _reply(send, 403, "Origin is not allowed.")
373
+ return
374
+ try:
375
+ _get_request_identity()
376
+ except RequestIdentityError:
377
+ await _reply(send, 401, "Authenticated request user could not be resolved.")
378
+ return
379
+ # Stateless profile: a session identifier never selects server-side state.
380
+ scope = dict(scope)
381
+ scope["headers"] = [
382
+ (key, value)
383
+ for key, value in scope.get("headers", ())
384
+ if key.lower() != b"mcp-session-id"
385
+ ]
386
+ await self.app(scope, receive, send)
387
+
388
+
389
+ def install_mcp(app, mcp_app, *, lifespan):
390
+ """Serve an application-owned MCP server at ``/mcp`` inside request identity.
391
+
392
+ ``mcp_app`` is the server's ASGI application, answering Streamable HTTP at
393
+ ``/mcp`` statelessly with JSON responses. ``lifespan`` is a zero-argument
394
+ callable returning its async context manager. With the official MCP Python
395
+ SDK, pass ``mcp.streamable_http_app()`` and ``mcp.session_manager.run``.
396
+
397
+ Install request identity first. Tools read the caller with
398
+ ``User.get_logged_user()`` and, on a requester-bound call, the person it
399
+ works for with ``User.get_requester()``. Only authenticated POSTs reach the
400
+ server; other methods answer 405 and an ``Origin`` other than the release's
401
+ own or its CORS origins answers 403. See ADR 0038.
402
+ """
403
+ from starlette.routing import Route
404
+
405
+ identity = getattr(app.state, _INSTALLATION, None)
406
+ if not isinstance(identity, dict) or identity.get("installed") is not True:
407
+ raise RuntimeError(
408
+ "Install request identity before MCP: call install_request_identity(app)."
409
+ )
410
+ if getattr(app.state, _MCP_INSTALLATION, None) is not None:
411
+ raise RuntimeError("MCP is already installed.")
412
+ if any(
413
+ path == _MCP_PATH or path.startswith(_MCP_PATH + "/")
414
+ for _, path in identity.get("public_ingress", ())
415
+ ):
416
+ raise RuntimeError(f"{_MCP_PATH} cannot be public ingress; MCP requests are authenticated.")
417
+ if not callable(mcp_app) or not callable(lifespan):
418
+ raise TypeError("install_mcp needs the MCP ASGI app and its lifespan factory.")
419
+ _mcp_conflicts(app.router.routes)
420
+
421
+ route = Route(
422
+ _MCP_PATH,
423
+ endpoint=_McpEndpoint(mcp_app, allowed_origins=_mcp_allowed_origins()),
424
+ include_in_schema=False,
425
+ )
426
+ app.router.routes.insert(0, route)
427
+ application_lifespan = app.router.lifespan_context
428
+ started = False
429
+
430
+ @asynccontextmanager
431
+ async def composed_lifespan(application):
432
+ nonlocal started
433
+ if started:
434
+ raise RuntimeError("The MCP lifespan runs once per application.")
435
+ started = True
436
+ _mcp_conflicts(app.router.routes, installed=route)
437
+ async with application_lifespan(application) as state:
438
+ async with lifespan():
439
+ yield state
440
+
441
+ app.router.lifespan_context = composed_lifespan
442
+ setattr(
443
+ app.state,
444
+ _MCP_INSTALLATION,
445
+ {
446
+ "installed": True,
447
+ "path": _MCP_PATH,
448
+ "transport": "streamable-http",
449
+ "stateless": True,
450
+ "request_identity": True,
451
+ },
452
+ )
453
+
454
+
455
+ __all__ = ["install_mcp", "install_request_identity", "reads_as_caller"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.15.dev57
3
+ Version: 9.0.16.dev59
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -31,6 +31,8 @@ Requires-Dist: requests>=2.33.0
31
31
  Requires-Dist: structlog
32
32
  Requires-Dist: typer
33
33
  Requires-Dist: urllib3>=2.7.0
34
+ Provides-Extra: mcp
35
+ Requires-Dist: mcp<2,>=1.28; extra == "mcp"
34
36
  Dynamic: license-file
35
37
 
36
38
  <p align="center">
@@ -1,6 +1,6 @@
1
1
  agent_scaffold/AGENTS.md,sha256=-tK1zUP6j3QAlFXmwFVSNPfbzchZRJSzj26L-RU9Gis,6471
2
2
  agent_scaffold/skills/a2a_sdk_execution/SKILL.md,sha256=NKtcXibE8TxZzKzz1KzJ2cVZ_EeuJJkb660U6wLvZ3s,11809
3
- agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md,sha256=HODSJCgm6Wdpd-nBsbMCb4Ls67P7DqK-j_S4GLFdfIk,19087
3
+ agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md,sha256=1lPnRtm6qVaqOu_lJkk8V7HJ1NjKsp0hb8LB9xwzPOI,19608
4
4
  agent_scaffold/skills/mainsequence-inference/SKILL.md,sha256=yv8aAHLQ2FBBSWPIFadY7pGNTwtR-yiR_-UzaGRN9-g,12502
5
5
  agent_scaffold/skills/maintenance/bug_auditor/SKILL.md,sha256=CCKs7edyZ9RK-WtAgXJXucokJFXZxk8sRUe0_imFtu8,8610
6
6
  agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md,sha256=pPsZaERlfokSJ6b6vLjpA5U0KuKxbOFzzcxOVW_ikiI,17935
@@ -44,7 +44,7 @@ mainsequence/client/exceptions.py,sha256=xx2L4l3OMNsTybbYkiTKK398ahnutEascM0It35
44
44
  mainsequence/client/github_issues.py,sha256=pSTUw3UPrmE6wqnpoJctartTaNSq6iu6PQRrd8hip6I,14568
45
45
  mainsequence/client/inference.py,sha256=tYKHRxLR6mA5iLmgd3qURwweWc2OPQuw0__tD3LQgSI,7748
46
46
  mainsequence/client/models_foundry.py,sha256=JwQdPLDSO4dZI-KC0SUdmN6izmNIKMb9IzZlYXy0Mig,36488
47
- mainsequence/client/models_helpers.py,sha256=nlbHuQAylTOHAZOq4ltJQp6QvC3NW_kw7tZFosGc5rM,47311
47
+ mainsequence/client/models_helpers.py,sha256=CQRjBjx3DsREJLRk0vqFsC-Dcll5lUP2ofu6e5UMvhk,49051
48
48
  mainsequence/client/models_user.py,sha256=tOngVHxkoEeV-kLLK3cmfESN9gzyXrLlZHFNGVv014Q,45241
49
49
  mainsequence/client/observability.py,sha256=_WrDKCaNk_TXmKElKCXC1inork6Exxxe8FRY4m1GI9s,16008
50
50
  mainsequence/client/utils.py,sha256=C0h0U-fOBZw3UxIwHcBTLf8CDClAOatf_oRgjvqFth4,31637
@@ -53,10 +53,10 @@ mainsequence/instrumentation/__init__.py,sha256=J_9XmfjprElvz0u2t9rTyJT0kFdyKpxd
53
53
  mainsequence/instrumentation/utils.py,sha256=b-ihjSioAepeLfMpudM2SqCtvnxMqIXileHx3klBCQU,3377
54
54
  mainsequence/server/__init__.py,sha256=BHUAhtdWPDRdxX07NYO_6wrBv5mf0GsXflecO52PmUc,73
55
55
  mainsequence/server/caller_assertions.py,sha256=Ix94Kvei31lKb861q6LqKzcB5H1mWnuH_wYwpXEKS1E,12351
56
- mainsequence/server/fastapi.py,sha256=1l5uIx4jqw092NJwXaA57nI6sAt9a_zXzJKgD4I1NRk,12090
57
- mainsequence-9.0.15.dev57.dist-info/licenses/LICENSE,sha256=b6Ecsk8wjsEby3lbSzdljR6hHiFGHBizt9iWV2p1c_I,1074
58
- mainsequence-9.0.15.dev57.dist-info/METADATA,sha256=AxjdXTfjT_TVHTrYRdQljivI9c15SpW9jhtZWyKUzgc,7403
59
- mainsequence-9.0.15.dev57.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
60
- mainsequence-9.0.15.dev57.dist-info/entry_points.txt,sha256=XGAOq3-mF2lWWpeRJvoaWyMarRcK167mH1g124aGdUs,88
61
- mainsequence-9.0.15.dev57.dist-info/top_level.txt,sha256=BFeblM_Q9q0cUWBvTxnfWcWQEHtf5XZi9nOS-Gdfwus,28
62
- mainsequence-9.0.15.dev57.dist-info/RECORD,,
56
+ mainsequence/server/fastapi.py,sha256=jB0EpxTqBw6sChtIGdPHGkC4I2MdV1V0uEd34NLvGQs,18267
57
+ mainsequence-9.0.16.dev59.dist-info/licenses/LICENSE,sha256=b6Ecsk8wjsEby3lbSzdljR6hHiFGHBizt9iWV2p1c_I,1074
58
+ mainsequence-9.0.16.dev59.dist-info/METADATA,sha256=k4UZhJV0jqz8epWSzNgtFkvEMOdf2mTJruMTCZNBn78,7467
59
+ mainsequence-9.0.16.dev59.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
60
+ mainsequence-9.0.16.dev59.dist-info/entry_points.txt,sha256=XGAOq3-mF2lWWpeRJvoaWyMarRcK167mH1g124aGdUs,88
61
+ mainsequence-9.0.16.dev59.dist-info/top_level.txt,sha256=BFeblM_Q9q0cUWBvTxnfWcWQEHtf5XZi9nOS-Gdfwus,28
62
+ mainsequence-9.0.16.dev59.dist-info/RECORD,,