mainsequence 9.0.15.dev57__tar.gz → 9.0.15.dev58__tar.gz

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.
Files changed (105) hide show
  1. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/PKG-INFO +3 -1
  2. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +10 -0
  3. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/models_helpers.py +47 -0
  4. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/server/fastapi.py +159 -3
  5. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence.egg-info/PKG-INFO +3 -1
  6. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence.egg-info/SOURCES.txt +1 -0
  7. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence.egg-info/requires.txt +3 -0
  8. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/pyproject.toml +8 -1
  9. mainsequence-9.0.15.dev58/tests/test_fastapi_mcp.py +339 -0
  10. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/LICENSE +0 -0
  11. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/README.md +0 -0
  12. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/AGENTS.md +0 -0
  13. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +0 -0
  14. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/mainsequence-inference/SKILL.md +0 -0
  15. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
  16. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +0 -0
  17. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/maintenance/metatables_transition/SKILL.md +0 -0
  18. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
  19. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
  20. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +0 -0
  21. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/__init__.py +0 -0
  22. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/__main__.py +0 -0
  23. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/_request_identity.py +0 -0
  24. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/bootstrap.py +0 -0
  25. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/__init__.py +0 -0
  26. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/api.py +0 -0
  27. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/browser_auth.py +0 -0
  28. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/cli.py +0 -0
  29. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/code_repository_status.py +0 -0
  30. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/config.py +0 -0
  31. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/docker_utils.py +0 -0
  32. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/doctor.py +0 -0
  33. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/local_ops.py +0 -0
  34. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/model_filters.py +0 -0
  35. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/pydantic_cli.py +0 -0
  36. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/sdk_utils.py +0 -0
  37. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/ssh_utils.py +0 -0
  38. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/ui.py +0 -0
  39. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/__init__.py +0 -0
  40. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/_transport.py +0 -0
  41. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/agent_runtime_models.py +0 -0
  42. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/base.py +0 -0
  43. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/client.py +0 -0
  44. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/compute_validation.py +0 -0
  45. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/exceptions.py +0 -0
  46. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/github_issues.py +0 -0
  47. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/inference.py +0 -0
  48. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/models_foundry.py +0 -0
  49. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/models_user.py +0 -0
  50. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/observability.py +0 -0
  51. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/utils.py +0 -0
  52. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/value_sets.py +0 -0
  53. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/code_repository_context.py +0 -0
  54. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/code_repository_skills.py +0 -0
  55. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/defaults.py +0 -0
  56. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/instrumentation/__init__.py +0 -0
  57. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/instrumentation/utils.py +0 -0
  58. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/logconf.py +0 -0
  59. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/repository_identity_security.py +0 -0
  60. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/runtime_credential_exchange.py +0 -0
  61. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/runtime_flags.py +0 -0
  62. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/scaffold_skills.py +0 -0
  63. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/server/__init__.py +0 -0
  64. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/server/caller_assertions.py +0 -0
  65. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence.egg-info/dependency_links.txt +0 -0
  66. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence.egg-info/entry_points.txt +0 -0
  67. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence.egg-info/top_level.txt +0 -0
  68. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/setup.cfg +0 -0
  69. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_auth_bootstrap.py +0 -0
  70. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_auth_precedence.py +0 -0
  71. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_caller_assertions.py +0 -0
  72. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_canonical_api_route_contract.py +0 -0
  73. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_cli_auth_contract.py +0 -0
  74. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_cli_browser_auth.py +0 -0
  75. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_cli_secure_storage.py +0 -0
  76. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_client.py +0 -0
  77. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_code_repository_branch_contract.py +0 -0
  78. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_code_repository_documentation_contract.py +0 -0
  79. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_code_repository_runtime_context.py +0 -0
  80. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_code_repository_skills.py +0 -0
  81. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_credential_repr.py +0 -0
  82. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_dependency_extras.py +0 -0
  83. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_dev_release_version.py +0 -0
  84. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_environment_resource_context.py +0 -0
  85. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_environment_response_contracts.py +0 -0
  86. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_fastapi_request_identity.py +0 -0
  87. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_github_issues_client.py +0 -0
  88. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_http_retry_budget.py +0 -0
  89. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_independent_context.py +0 -0
  90. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_inference_client.py +0 -0
  91. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_instrumentation.py +0 -0
  92. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_job_client.py +0 -0
  93. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_local_ops.py +0 -0
  94. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_logconf.py +0 -0
  95. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_models_user_request_bound_auth.py +0 -0
  96. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_notification_uid_contract.py +0 -0
  97. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_observability_client.py +0 -0
  98. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_runtime_credential_exchange.py +0 -0
  99. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_scaffold_skills.py +0 -0
  100. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_secret_client_model.py +0 -0
  101. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_source_ownership.py +0 -0
  102. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_ssh_utils.py +0 -0
  103. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_tolerant_response_reading.py +0 -0
  104. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_tolerant_value_sets.py +0 -0
  105. {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_user_application_facts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.15.dev57
3
+ Version: 9.0.15.dev58
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">
@@ -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.15.dev58
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">
@@ -80,6 +80,7 @@ tests/test_dependency_extras.py
80
80
  tests/test_dev_release_version.py
81
81
  tests/test_environment_resource_context.py
82
82
  tests/test_environment_response_contracts.py
83
+ tests/test_fastapi_mcp.py
83
84
  tests/test_fastapi_request_identity.py
84
85
  tests/test_github_issues_client.py
85
86
  tests/test_http_retry_budget.py
@@ -16,3 +16,6 @@ requests>=2.33.0
16
16
  structlog
17
17
  typer
18
18
  urllib3>=2.7.0
19
+
20
+ [mcp]
21
+ mcp<2,>=1.28
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mainsequence"
7
- version = "9.0.15.dev57"
7
+ version = "9.0.15.dev58"
8
8
  description = "Main Sequence SDK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -42,6 +42,12 @@ dependencies = [
42
42
  "urllib3>=2.7.0",
43
43
  ]
44
44
 
45
+ [project.optional-dependencies]
46
+ # Serving an application-owned MCP server with install_mcp (ADR 0038).
47
+ mcp = [
48
+ "mcp>=1.28,<2",
49
+ ]
50
+
45
51
  [project.urls]
46
52
  Homepage = "https://github.com/mainsequence-sdk/mainsequence-sdk"
47
53
  Issues = "https://github.com/mainsequence-sdk/mainsequence-sdk/issues"
@@ -54,6 +60,7 @@ ms = "mainsequence.cli.cli:app"
54
60
  dev = [
55
61
  "fastapi>=0.110,<1",
56
62
  "httpx>=0.27,<1",
63
+ "mcp>=1.28,<2",
57
64
  "black>=26.3.1",
58
65
  "IPython",
59
66
  "mkdocs",
@@ -0,0 +1,339 @@
1
+ """An application-owned MCP server served by install_mcp (ADR 0038)."""
2
+
3
+ import asyncio
4
+ import json
5
+ from contextlib import asynccontextmanager
6
+ from unittest.mock import Mock
7
+ from uuid import uuid4
8
+
9
+ import anyio
10
+ import httpx
11
+ import pytest
12
+ from fastapi import FastAPI, Request
13
+ from fastapi.testclient import TestClient
14
+ from mcp.server.fastmcp import FastMCP
15
+ from mcp.server.transport_security import TransportSecuritySettings
16
+
17
+ import tests.test_fastapi_request_identity as identity_tests
18
+ from mainsequence.client import User
19
+ from mainsequence.server.caller_assertions import ASSERTION_HEADER
20
+ from mainsequence.server.fastapi import install_mcp, install_request_identity
21
+
22
+ USER, OTHER, ISSUER = identity_tests.USER, identity_tests.OTHER, identity_tests.ISSUER
23
+ clean_environment = identity_tests.clean_environment
24
+ signed = identity_tests.signed
25
+
26
+ MCP_HEADERS = {
27
+ "accept": "application/json, text/event-stream",
28
+ "content-type": "application/json",
29
+ "mcp-protocol-version": "2025-11-25",
30
+ }
31
+
32
+
33
+ @pytest.fixture(autouse=True)
34
+ def no_cors_origins(monkeypatch):
35
+ monkeypatch.delenv("FASTAPI_CORS_ALLOW_ORIGINS", raising=False)
36
+
37
+
38
+ def _call(name):
39
+ return {
40
+ "jsonrpc": "2.0",
41
+ "id": 1,
42
+ "method": "tools/call",
43
+ "params": {"name": name, "arguments": {}},
44
+ }
45
+
46
+
47
+ def _text(response):
48
+ assert response.status_code == 200, response.text
49
+ result = response.json()["result"]
50
+ assert result.get("isError") is not True, result
51
+ return result["content"][0]["text"]
52
+
53
+
54
+ def _server():
55
+ """A stateless JSON server; the SDK checks Origin and the platform routes Host."""
56
+ return FastMCP(
57
+ "identity",
58
+ stateless_http=True,
59
+ json_response=True,
60
+ transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
61
+ )
62
+
63
+
64
+ def _identity_server():
65
+ server = _server()
66
+
67
+ @server.tool()
68
+ async def caller() -> str:
69
+ return User.get_logged_user().uid
70
+
71
+ @server.tool()
72
+ async def caller_in_thread() -> str:
73
+ return await anyio.to_thread.run_sync(lambda: User.get_logged_user().uid)
74
+
75
+ @server.tool()
76
+ async def requester() -> str:
77
+ person = User.get_requester()
78
+ return person.uid if person is not None else "none"
79
+
80
+ return server
81
+
82
+
83
+ def _install(app, server):
84
+ install_request_identity(app)
85
+ install_mcp(app, server.streamable_http_app(), lifespan=server.session_manager.run)
86
+ return app
87
+
88
+
89
+ async def _fake_mcp(scope, receive, send):
90
+ await send(
91
+ {
92
+ "type": "http.response.start",
93
+ "status": 200,
94
+ "headers": [(b"content-type", b"application/json")],
95
+ }
96
+ )
97
+ headers = {key.decode("latin-1"): value.decode("latin-1") for key, value in scope["headers"]}
98
+ await send({"type": "http.response.body", "body": json.dumps(headers).encode()})
99
+
100
+
101
+ @asynccontextmanager
102
+ async def _no_lifespan():
103
+ yield
104
+
105
+
106
+ def test_tools_read_the_verified_caller_and_rest_routes_keep_working(signed):
107
+ token, _, _ = signed
108
+ app = FastAPI()
109
+
110
+ @app.get("/prices")
111
+ def prices():
112
+ return {"caller": User.get_logged_user().uid}
113
+
114
+ _install(app, _identity_server())
115
+ proof = {**MCP_HEADERS, ASSERTION_HEADER: token()}
116
+ requester_bound = {
117
+ **MCP_HEADERS,
118
+ ASSERTION_HEADER: token(requester={"sub": OTHER, "team_uids": []}),
119
+ }
120
+
121
+ with TestClient(app) as client:
122
+ assert _text(client.post("/mcp", json=_call("caller"), headers=proof)) == USER
123
+ assert _text(client.post("/mcp", json=_call("caller_in_thread"), headers=proof)) == USER
124
+ assert _text(client.post("/mcp", json=_call("requester"), headers=proof)) == "none"
125
+ assert _text(client.post("/mcp", json=_call("requester"), headers=requester_bound)) == OTHER
126
+ assert client.get("/prices", headers={ASSERTION_HEADER: token()}).json() == {"caller": USER}
127
+ missing = client.post("/mcp", json=_call("caller"), headers=MCP_HEADERS)
128
+ forged = client.post(
129
+ "/mcp",
130
+ json=_call("caller"),
131
+ headers={**MCP_HEADERS, ASSERTION_HEADER: token(aud="urn:other:" + str(uuid4()))},
132
+ )
133
+
134
+ assert (missing.status_code, forged.status_code) == (401, 401)
135
+ assert "/mcp" not in app.openapi()["paths"]
136
+
137
+
138
+ def test_concurrent_callers_keep_their_own_identity(signed):
139
+ token, _, _ = signed
140
+ server = _server()
141
+ both_inside = asyncio.Barrier(2)
142
+
143
+ @server.tool()
144
+ async def caller_after_peer() -> str:
145
+ await asyncio.wait_for(both_inside.wait(), 5)
146
+ return User.get_logged_user().uid
147
+
148
+ app = _install(FastAPI(), server)
149
+
150
+ async def run():
151
+ async with app.router.lifespan_context(app):
152
+ transport = httpx.ASGITransport(app=app)
153
+ async with httpx.AsyncClient(
154
+ transport=transport, base_url="http://testserver"
155
+ ) as client:
156
+
157
+ async def call(user):
158
+ headers = {**MCP_HEADERS, ASSERTION_HEADER: token(user)}
159
+ return _text(
160
+ await client.post("/mcp", json=_call("caller_after_peer"), headers=headers)
161
+ )
162
+
163
+ return await asyncio.gather(call(USER), call(OTHER))
164
+
165
+ assert asyncio.run(run()) == [USER, OTHER]
166
+
167
+
168
+ def test_endpoint_declines_streams_sessions_and_foreign_origins(signed, monkeypatch):
169
+ token, _, _ = signed
170
+ monkeypatch.setenv("FASTAPI_PUBLIC_BASE_URL", "https://prices.example.test/")
171
+ monkeypatch.setenv(
172
+ "FASTAPI_CORS_ALLOW_ORIGINS", "https://app.example.test, https://*.tenant.example.test"
173
+ )
174
+ app = FastAPI()
175
+ install_request_identity(app)
176
+ install_mcp(app, _fake_mcp, lifespan=_no_lifespan)
177
+ proof = {ASSERTION_HEADER: token()}
178
+
179
+ with TestClient(app) as client:
180
+ stream = client.get("/mcp", headers=proof)
181
+ delete = client.delete("/mcp", headers=proof)
182
+ allowed = [
183
+ client.post("/mcp", json={}, headers={**proof, "origin": origin}).status_code
184
+ for origin in (
185
+ "https://prices.example.test",
186
+ "https://app.example.test",
187
+ "https://east.tenant.example.test",
188
+ )
189
+ ]
190
+ refused = [
191
+ client.post("/mcp", json={}, headers={**proof, "origin": origin}).status_code
192
+ for origin in (
193
+ "https://evil.example.test",
194
+ "https://a.b.tenant.example.test",
195
+ "null",
196
+ )
197
+ ]
198
+ duplicated = client.post(
199
+ "/mcp",
200
+ json={},
201
+ headers=[
202
+ (ASSERTION_HEADER, proof[ASSERTION_HEADER]),
203
+ ("origin", "https://app.example.test"),
204
+ ("origin", "https://evil.example.test"),
205
+ ],
206
+ )
207
+ with_session = client.post("/mcp", json={}, headers={**proof, "mcp-session-id": "abc"})
208
+
209
+ assert (stream.status_code, stream.headers["allow"]) == (405, "POST")
210
+ assert delete.status_code == 405
211
+ assert allowed == [200, 200, 200]
212
+ assert refused == [403, 403, 403]
213
+ assert duplicated.status_code == 403
214
+ assert with_session.status_code == 200
215
+ assert "mcp-session-id" not in with_session.json()
216
+
217
+
218
+ def test_installation_rejects_unsafe_or_conflicting_setups(signed, monkeypatch):
219
+ with pytest.raises(RuntimeError, match="request identity"):
220
+ install_mcp(FastAPI(), _fake_mcp, lifespan=_no_lifespan)
221
+
222
+ routed = FastAPI()
223
+ routed.get("/mcp/tools")(lambda: {})
224
+ install_request_identity(routed)
225
+ with pytest.raises(RuntimeError, match="conflicts"):
226
+ install_mcp(routed, _fake_mcp, lifespan=_no_lifespan)
227
+
228
+ mounted = FastAPI()
229
+ mounted.mount("/mcp", _fake_mcp)
230
+ install_request_identity(mounted)
231
+ with pytest.raises(RuntimeError, match="conflicts"):
232
+ install_mcp(mounted, _fake_mcp, lifespan=_no_lifespan)
233
+
234
+ app = FastAPI()
235
+ install_request_identity(app)
236
+ with pytest.raises(TypeError):
237
+ install_mcp(app, _fake_mcp, lifespan=None)
238
+ install_mcp(app, _fake_mcp, lifespan=_no_lifespan)
239
+ with pytest.raises(RuntimeError, match="already installed"):
240
+ install_mcp(app, _fake_mcp, lifespan=_no_lifespan)
241
+ assert app.state.mainsequence_mcp == {
242
+ "installed": True,
243
+ "path": "/mcp",
244
+ "transport": "streamable-http",
245
+ "stateless": True,
246
+ "request_identity": True,
247
+ }
248
+ assert app.state.mainsequence_request_identity["installed"] is True
249
+
250
+ app.get("/mcp/late")(lambda: {})
251
+ with pytest.raises(RuntimeError, match="conflicts"):
252
+ with TestClient(app):
253
+ pass
254
+
255
+ monkeypatch.setenv("FASTAPI_PUBLIC_INGRESS", json.dumps([{"method": "POST", "path": "/mcp"}]))
256
+ public = FastAPI()
257
+ install_request_identity(public)
258
+ with pytest.raises(RuntimeError, match="public ingress"):
259
+ install_mcp(public, _fake_mcp, lifespan=_no_lifespan)
260
+
261
+
262
+ def test_lifespans_start_once_in_order_and_unwind_on_failure(signed):
263
+ token, _, _ = signed
264
+ events = []
265
+
266
+ @asynccontextmanager
267
+ async def application_lifespan(app):
268
+ events.append("app start")
269
+ try:
270
+ yield {"greeting": "hello"}
271
+ finally:
272
+ events.append("app stop")
273
+
274
+ @asynccontextmanager
275
+ async def mcp_lifespan():
276
+ events.append("mcp start")
277
+ yield
278
+ events.append("mcp stop")
279
+
280
+ app = FastAPI(lifespan=application_lifespan)
281
+
282
+ @app.get("/greeting")
283
+ def greeting(request: Request):
284
+ return {"greeting": request.state.greeting}
285
+
286
+ install_request_identity(app)
287
+ install_mcp(app, _fake_mcp, lifespan=mcp_lifespan)
288
+ with TestClient(app) as client:
289
+ assert client.get("/greeting", headers={ASSERTION_HEADER: token()}).json() == {
290
+ "greeting": "hello"
291
+ }
292
+ assert events == ["app start", "mcp start", "mcp stop", "app stop"]
293
+ with pytest.raises(RuntimeError, match="once"):
294
+ with TestClient(app):
295
+ pass
296
+
297
+ @asynccontextmanager
298
+ async def failing_mcp_lifespan():
299
+ raise RuntimeError("MCP server failed to start")
300
+ yield
301
+
302
+ events.clear()
303
+ broken = FastAPI(lifespan=application_lifespan)
304
+ install_request_identity(broken)
305
+ install_mcp(broken, _fake_mcp, lifespan=failing_mcp_lifespan)
306
+ with pytest.raises(RuntimeError, match="failed to start"):
307
+ with TestClient(broken):
308
+ pass
309
+ assert events == ["app start", "app stop"]
310
+
311
+
312
+ def test_local_mode_admits_mcp_through_the_same_bearer_validation(monkeypatch):
313
+ monkeypatch.setenv("MAINSEQUENCE_ENDPOINT", ISSUER)
314
+
315
+ def users_me(url, **kwargs):
316
+ if kwargs["headers"] == {"Authorization": "Bearer developer-token"}:
317
+ response = Mock(status_code=200)
318
+ response.json.return_value = {"uid": USER, "username": "developer"}
319
+ return response
320
+ # The platform refuses a token issued for another resource, such as an MCP URL.
321
+ return Mock(status_code=401)
322
+
323
+ monkeypatch.setattr("mainsequence.server.fastapi.requests.get", users_me)
324
+ app = _install(FastAPI(), _identity_server())
325
+
326
+ with TestClient(app) as client:
327
+ developer = client.post(
328
+ "/mcp",
329
+ json=_call("caller"),
330
+ headers={**MCP_HEADERS, "Authorization": "Bearer developer-token"},
331
+ )
332
+ other_resource = client.post(
333
+ "/mcp",
334
+ json=_call("caller"),
335
+ headers={**MCP_HEADERS, "Authorization": "Bearer token-for-a-project-mcp-url"},
336
+ )
337
+
338
+ assert _text(developer) == USER
339
+ assert other_resource.status_code == 401