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.
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/PKG-INFO +3 -1
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +10 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/models_helpers.py +47 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/server/fastapi.py +159 -3
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence.egg-info/PKG-INFO +3 -1
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence.egg-info/SOURCES.txt +1 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence.egg-info/requires.txt +3 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/pyproject.toml +8 -1
- mainsequence-9.0.15.dev58/tests/test_fastapi_mcp.py +339 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/LICENSE +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/README.md +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/AGENTS.md +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/a2a_sdk_execution/SKILL.md +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/mainsequence-inference/SKILL.md +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/maintenance/bug_auditor/SKILL.md +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/maintenance/metatables_transition/SKILL.md +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/__init__.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/__main__.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/_request_identity.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/bootstrap.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/__init__.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/api.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/browser_auth.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/cli.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/code_repository_status.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/config.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/docker_utils.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/doctor.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/local_ops.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/model_filters.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/pydantic_cli.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/sdk_utils.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/ssh_utils.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/ui.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/__init__.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/_transport.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/agent_runtime_models.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/base.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/client.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/compute_validation.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/exceptions.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/github_issues.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/inference.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/models_foundry.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/models_user.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/observability.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/utils.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/value_sets.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/code_repository_context.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/code_repository_skills.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/defaults.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/instrumentation/__init__.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/instrumentation/utils.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/logconf.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/repository_identity_security.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/runtime_credential_exchange.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/runtime_flags.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/scaffold_skills.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/server/__init__.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/server/caller_assertions.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence.egg-info/dependency_links.txt +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence.egg-info/entry_points.txt +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence.egg-info/top_level.txt +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/setup.cfg +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_auth_bootstrap.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_auth_precedence.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_caller_assertions.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_canonical_api_route_contract.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_cli_auth_contract.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_cli_browser_auth.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_cli_secure_storage.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_client.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_code_repository_branch_contract.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_code_repository_documentation_contract.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_code_repository_runtime_context.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_code_repository_skills.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_credential_repr.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_dependency_extras.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_dev_release_version.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_environment_resource_context.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_environment_response_contracts.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_fastapi_request_identity.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_github_issues_client.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_http_retry_budget.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_independent_context.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_inference_client.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_instrumentation.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_job_client.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_local_ops.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_logconf.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_models_user_request_bound_auth.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_notification_uid_contract.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_observability_client.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_runtime_credential_exchange.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_scaffold_skills.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_secret_client_model.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_source_ownership.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_ssh_utils.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_tolerant_response_reading.py +0 -0
- {mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_tolerant_value_sets.py +0 -0
- {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.
|
|
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.
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/models_helpers.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mainsequence"
|
|
7
|
-
version = "9.0.15.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/cli/code_repository_status.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/agent_runtime_models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/compute_validation.py
RENAMED
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/github_issues.py
RENAMED
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/models_foundry.py
RENAMED
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/client/observability.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/code_repository_context.py
RENAMED
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/code_repository_skills.py
RENAMED
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/instrumentation/__init__.py
RENAMED
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/instrumentation/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/repository_identity_security.py
RENAMED
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/runtime_credential_exchange.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence/server/caller_assertions.py
RENAMED
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/mainsequence.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_canonical_api_route_contract.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_code_repository_skills.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_environment_resource_context.py
RENAMED
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_environment_response_contracts.py
RENAMED
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_fastapi_request_identity.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_models_user_request_bound_auth.py
RENAMED
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_notification_uid_contract.py
RENAMED
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_runtime_credential_exchange.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_tolerant_response_reading.py
RENAMED
|
File without changes
|
|
File without changes
|
{mainsequence-9.0.15.dev57 → mainsequence-9.0.15.dev58}/tests/test_user_application_facts.py
RENAMED
|
File without changes
|