feat(org): model grants, org memory, MCP gateway (v2 phases A1-3..5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
c93b25741e
commit
dee2f72c46
@@ -2,10 +2,358 @@
|
||||
|
||||
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
|
||||
route its full path (no prefix is applied here besides ``/api``).
|
||||
|
||||
The rule this router enforces: **an organization may only use the models its
|
||||
owner granted it.** The endpoints here are the only way a grant appears, so
|
||||
revoking one is immediate (``ModelGrantService.is_granted`` returns false on the
|
||||
next call) rather than a change that only takes effect after a cache expires.
|
||||
|
||||
Everything is owner-only, guarded at the router level, and every mutation is
|
||||
audited. The model *names* live here and nowhere else: organization users only
|
||||
ever see a neutral tier («سریع» / «متعادل» / «قوی»).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from dataclasses import asdict
|
||||
|
||||
router = APIRouter(tags=["admin-grants"])
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.model_grant_service import (
|
||||
ModelGrantService,
|
||||
ModelView,
|
||||
OrgGrantView,
|
||||
PolicyView,
|
||||
)
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import ValidationError
|
||||
from app.domain.identity.audit_actions import (
|
||||
DEFAULT_MODEL_GRANT_REMOVE,
|
||||
DEFAULT_MODEL_GRANT_SET,
|
||||
MODEL_GRANT_REVOKE,
|
||||
MODEL_GRANT_SET,
|
||||
ORG_POLICY_UPDATE,
|
||||
)
|
||||
from app.domain.identity.models import User
|
||||
from app.interfaces.http.deps import client_meta, require_platform_owner
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin",
|
||||
tags=["admin-grants"],
|
||||
# Deny-by-default, exactly like the rest of the console.
|
||||
dependencies=[Depends(require_platform_owner)],
|
||||
)
|
||||
|
||||
|
||||
async def get_model_grant_service(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ModelGrantService:
|
||||
from app.application.container_org import build_model_grant_service
|
||||
|
||||
return build_model_grant_service(session)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModelViewOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
ai_model_id: str
|
||||
label: str
|
||||
provider_id: str
|
||||
capabilities: list[str]
|
||||
tier: str
|
||||
tier_label: str
|
||||
output_price_per_1m: float
|
||||
model_active: bool
|
||||
granted: bool
|
||||
daily_credit_cap: float | None = None
|
||||
|
||||
|
||||
class OrgGrantOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
ai_model_id: str
|
||||
label: str
|
||||
tier: str
|
||||
tier_label: str
|
||||
capabilities: list[str]
|
||||
daily_credit_cap: float | None = None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class OrgGrantsOut(BaseModel):
|
||||
"""The grant matrix for one organization: every active model + its state."""
|
||||
|
||||
organization_id: str
|
||||
models: list[ModelViewOut]
|
||||
|
||||
|
||||
class SetGrantRequest(BaseModel):
|
||||
#: ``None`` means "no per-model daily cap"; ``0`` means "granted but zeroed".
|
||||
daily_credit_cap: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class SetDefaultGrantRequest(BaseModel):
|
||||
is_active: bool = True
|
||||
daily_credit_cap: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class PolicyOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
organization_id: str
|
||||
max_employees: int
|
||||
monthly_credit_budget: float | None = None
|
||||
automation_enabled: bool
|
||||
mcp_enabled: bool
|
||||
max_employees_limit: int
|
||||
|
||||
|
||||
class UpdatePolicyRequest(BaseModel):
|
||||
max_employees: int | None = Field(default=None, ge=1)
|
||||
monthly_credit_budget: float | None = Field(default=None, ge=0)
|
||||
automation_enabled: bool | None = None
|
||||
mcp_enabled: bool | None = None
|
||||
|
||||
|
||||
def _model_out(view: ModelView) -> ModelViewOut:
|
||||
return ModelViewOut(**asdict(view))
|
||||
|
||||
|
||||
def _grant_out(view: OrgGrantView) -> OrgGrantOut:
|
||||
return OrgGrantOut(**asdict(view))
|
||||
|
||||
|
||||
def _policy_out(view: PolicyView) -> PolicyOut:
|
||||
return PolicyOut(**asdict(view))
|
||||
|
||||
|
||||
def _require_change(*values: object) -> None:
|
||||
if all(value is None for value in values):
|
||||
raise ValidationError("هیچ تغییری ارسال نشده است.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grants per organization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/model-grants/{organization_id}", response_model=OrgGrantsOut)
|
||||
async def list_org_grants(
|
||||
organization_id: str,
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
) -> OrgGrantsOut:
|
||||
"""Every active catalogue model, flagged with whether this org may use it."""
|
||||
models = await service.list_models(organization_id=organization_id)
|
||||
return OrgGrantsOut(
|
||||
organization_id=organization_id, models=[_model_out(m) for m in models]
|
||||
)
|
||||
|
||||
|
||||
@router.put("/model-grants/{organization_id}/models/{ai_model_id}", response_model=OrgGrantOut)
|
||||
async def set_org_grant(
|
||||
organization_id: str,
|
||||
ai_model_id: str,
|
||||
payload: SetGrantRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
# FastAPI caches ``get_session`` per request, so this is the very session
|
||||
# the service writes through: the audit row lands in the same transaction.
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> OrgGrantOut:
|
||||
"""Grant a model to an organization (idempotent) and/or set its daily cap."""
|
||||
granted = await service.grant_model(
|
||||
organization_id=organization_id,
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=payload.daily_credit_cap,
|
||||
actor_user_id=owner.id,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MODEL_GRANT_SET,
|
||||
organization_id=organization_id,
|
||||
actor_user_id=owner.id,
|
||||
target_type="ai_model",
|
||||
target_id=ai_model_id,
|
||||
# The model label is a name, not a secret, but keeping the audit rows
|
||||
# id-only makes them stable when the owner renames a model.
|
||||
metadata={"daily_credit_cap": payload.daily_credit_cap},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return _grant_out(granted)
|
||||
|
||||
|
||||
@router.get("/model-grants/{organization_id}/active", response_model=list[OrgGrantOut])
|
||||
async def list_active_org_grants(
|
||||
organization_id: str,
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
) -> list[OrgGrantOut]:
|
||||
"""Just the active grants — what the engine will actually be allowed to use."""
|
||||
grants = await service.list_grants(organization_id=organization_id, active_only=True)
|
||||
return [_grant_out(g) for g in grants]
|
||||
|
||||
|
||||
@router.delete("/model-grants/{organization_id}/models/{ai_model_id}")
|
||||
async def revoke_org_grant(
|
||||
organization_id: str,
|
||||
ai_model_id: str,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
"""Revoke a model from one organization. Other organizations are unaffected."""
|
||||
await service.revoke_model(
|
||||
organization_id=organization_id, ai_model_id=ai_model_id, actor_user_id=owner.id
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MODEL_GRANT_REVOKE,
|
||||
organization_id=organization_id,
|
||||
actor_user_id=owner.id,
|
||||
target_type="ai_model",
|
||||
target_id=ai_model_id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"organization_id": organization_id, "ai_model_id": ai_model_id, "granted": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The default set copied into every new organization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/default-model-grants", response_model=list[ModelViewOut])
|
||||
async def list_default_grants(
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
) -> list[ModelViewOut]:
|
||||
return [_model_out(m) for m in await service.list_defaults()]
|
||||
|
||||
|
||||
@router.put("/default-model-grants/{ai_model_id}", response_model=ModelViewOut)
|
||||
async def set_default_grant(
|
||||
ai_model_id: str,
|
||||
payload: SetDefaultGrantRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ModelViewOut:
|
||||
"""Add a model to the default set (or switch its default off without losing it)."""
|
||||
await service.set_default(
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=payload.daily_credit_cap,
|
||||
is_active=payload.is_active,
|
||||
actor_user_id=owner.id,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=DEFAULT_MODEL_GRANT_SET,
|
||||
actor_user_id=owner.id,
|
||||
target_type="ai_model",
|
||||
target_id=ai_model_id,
|
||||
metadata={
|
||||
"is_active": payload.is_active,
|
||||
"daily_credit_cap": payload.daily_credit_cap,
|
||||
},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
view = next(
|
||||
(m for m in await service.list_defaults() if m.ai_model_id == ai_model_id), None
|
||||
)
|
||||
assert view is not None
|
||||
return _model_out(view)
|
||||
|
||||
|
||||
@router.delete("/default-model-grants/{ai_model_id}")
|
||||
async def remove_default_grant(
|
||||
ai_model_id: str,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
"""Remove a model from the default set. Already-created orgs keep their grant."""
|
||||
await service.remove_default(ai_model_id=ai_model_id)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=DEFAULT_MODEL_GRANT_REMOVE,
|
||||
actor_user_id=owner.id,
|
||||
target_type="ai_model",
|
||||
target_id=ai_model_id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"ai_model_id": ai_model_id, "in_default_set": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Org policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/org-policies/{organization_id}", response_model=PolicyOut)
|
||||
async def get_org_policy(
|
||||
organization_id: str,
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
) -> PolicyOut:
|
||||
return _policy_out(await service.get_org_policy(organization_id))
|
||||
|
||||
|
||||
@router.put("/org-policies/{organization_id}", response_model=PolicyOut)
|
||||
async def update_org_policy(
|
||||
organization_id: str,
|
||||
payload: UpdatePolicyRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PolicyOut:
|
||||
"""Set the employee cap, monthly budget and automation switch for one org."""
|
||||
_require_change(
|
||||
payload.max_employees,
|
||||
payload.monthly_credit_budget,
|
||||
payload.automation_enabled,
|
||||
payload.mcp_enabled,
|
||||
)
|
||||
policy = await service.set_org_policy(
|
||||
organization_id=organization_id,
|
||||
max_employees=payload.max_employees,
|
||||
monthly_credit_budget=payload.monthly_credit_budget,
|
||||
automation_enabled=payload.automation_enabled,
|
||||
mcp_enabled=payload.mcp_enabled,
|
||||
actor_user_id=owner.id,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=ORG_POLICY_UPDATE,
|
||||
organization_id=organization_id,
|
||||
actor_user_id=owner.id,
|
||||
target_type="organization",
|
||||
target_id=organization_id,
|
||||
metadata={
|
||||
"max_employees": policy.max_employees,
|
||||
"monthly_credit_budget": policy.monthly_credit_budget,
|
||||
"automation_enabled": policy.automation_enabled,
|
||||
"mcp_enabled": policy.mcp_enabled,
|
||||
},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return _policy_out(policy)
|
||||
|
||||
@@ -2,10 +2,341 @@
|
||||
|
||||
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
|
||||
route its full path (no prefix is applied here besides ``/api``).
|
||||
|
||||
Two audiences, one rule each:
|
||||
|
||||
* an **organization admin** mints, lists and revokes its own tokens, and picks the
|
||||
scopes — a token can never be broader than the admin's own intent, and it can
|
||||
never be issued for another organization;
|
||||
* the **platform owner** holds the two kill switches: the gateway as a whole, and
|
||||
one organization's access to it.
|
||||
|
||||
The raw token is returned exactly once, by the create call, and is never stored —
|
||||
only its hash. Everything after that is the prefix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.container_org import build_model_grant_service
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.mcp_service import (
|
||||
MCP_SCOPES,
|
||||
McpService,
|
||||
as_utc,
|
||||
validate_scopes,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_session
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.identity.audit_actions import (
|
||||
MCP_GLOBAL_SWITCH,
|
||||
MCP_ORG_SWITCH,
|
||||
MCP_TOKEN_CREATE,
|
||||
MCP_TOKEN_REVOKE,
|
||||
)
|
||||
from app.domain.identity.models import Role, User
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_org_context,
|
||||
require_platform_owner,
|
||||
require_role_at_least,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["mcp-tokens"])
|
||||
|
||||
#: Minting a token is admin+ (it grants a third party real access).
|
||||
_admin_only = require_role_at_least(Role.ADMIN)
|
||||
|
||||
|
||||
async def _service(session: AsyncSession = Depends(get_session)) -> McpService:
|
||||
return McpService(session)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class McpTokenOut(BaseModel):
|
||||
id: str
|
||||
organization_id: str
|
||||
name: str
|
||||
prefix: str
|
||||
scopes: list[str]
|
||||
expires_at: datetime | None = None
|
||||
last_used_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
status: str
|
||||
|
||||
|
||||
class McpTokenCreatedOut(McpTokenOut):
|
||||
"""The only response that ever carries the raw token."""
|
||||
|
||||
token: str
|
||||
|
||||
|
||||
class McpTokenIn(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=120)
|
||||
scopes: list[str] = Field(min_length=1, max_length=len(MCP_SCOPES))
|
||||
ttl_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
|
||||
|
||||
class McpSettingsOut(BaseModel):
|
||||
organization_id: str
|
||||
mcp_enabled: bool
|
||||
gateway_enabled: bool
|
||||
scopes: list[str]
|
||||
token_count: int
|
||||
|
||||
|
||||
class McpGlobalOut(BaseModel):
|
||||
gateway_enabled: bool
|
||||
organizations: list[McpOrgStateOut]
|
||||
total_tokens: int
|
||||
|
||||
|
||||
class McpOrgStateOut(BaseModel):
|
||||
organization_id: str
|
||||
name: str
|
||||
slug: str
|
||||
mcp_enabled: bool
|
||||
token_count: int
|
||||
|
||||
|
||||
class McpGlobalIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class McpOrgSwitchIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
def _status(token) -> str:
|
||||
"""``active`` only while the token is neither revoked nor expired."""
|
||||
if token.revoked_at is not None:
|
||||
return "revoked"
|
||||
if as_utc(token.expires_at) is not None and as_utc(token.expires_at) <= utcnow():
|
||||
return "expired"
|
||||
return "active"
|
||||
|
||||
|
||||
def _out(token) -> McpTokenOut:
|
||||
return McpTokenOut(
|
||||
id=token.id,
|
||||
organization_id=token.organization_id,
|
||||
name=token.name,
|
||||
prefix=token.prefix,
|
||||
scopes=token.scopes,
|
||||
expires_at=token.expires_at,
|
||||
last_used_at=token.last_used_at,
|
||||
revoked_at=token.revoked_at,
|
||||
created_at=token.created_at,
|
||||
status=_status(token),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Organization-scoped tokens (admin+)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/organizations/{org_id}/mcp-tokens", response_model=list[McpTokenOut]
|
||||
)
|
||||
async def list_tokens(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: McpService = Depends(_service),
|
||||
) -> list[McpTokenOut]:
|
||||
"""Every token this organization ever minted, with its live status.
|
||||
|
||||
Deliberately includes revoked and expired rows: the history of who could
|
||||
reach the organization is the part that matters after an incident.
|
||||
"""
|
||||
return [_out(token) for token in await service.list(context.org_id)]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/organizations/{org_id}/mcp-tokens",
|
||||
response_model=McpTokenCreatedOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_token(
|
||||
payload: McpTokenIn,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_admin_only),
|
||||
service: McpService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> McpTokenCreatedOut:
|
||||
"""Mint a token. The raw value appears in this response and nowhere else."""
|
||||
issued = await service.issue(
|
||||
organization_id=context.org_id,
|
||||
name=payload.name,
|
||||
scopes=validate_scopes(payload.scopes),
|
||||
created_by_user_id=context.user.id,
|
||||
ttl_days=payload.ttl_days,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MCP_TOKEN_CREATE,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="mcp_token",
|
||||
target_id=issued.record.id,
|
||||
metadata={"prefix": issued.record.prefix, "scopes": issued.record.scopes},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return McpTokenCreatedOut(**_out(issued.record).model_dump(), token=issued.token)
|
||||
|
||||
|
||||
@router.delete("/organizations/{org_id}/mcp-tokens/{token_id}")
|
||||
async def revoke_token(
|
||||
token_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_admin_only),
|
||||
service: McpService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
await service.revoke(organization_id=context.org_id, token_id=token_id)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MCP_TOKEN_REVOKE,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="mcp_token",
|
||||
target_id=token_id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"id": token_id, "revoked": True}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/organizations/{org_id}/mcp-tokens/settings", response_model=McpSettingsOut
|
||||
)
|
||||
async def get_mcp_settings(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: McpService = Depends(_service),
|
||||
) -> McpSettingsOut:
|
||||
"""The switches this organization's own admins may see and the scope list."""
|
||||
policy = await build_model_grant_service(service.session).get_org_policy(
|
||||
context.org_id
|
||||
)
|
||||
tokens = await service.list(context.org_id)
|
||||
return McpSettingsOut(
|
||||
organization_id=context.org_id,
|
||||
mcp_enabled=policy.mcp_enabled,
|
||||
gateway_enabled=settings.mcp_gateway_enabled,
|
||||
scopes=list(MCP_SCOPES),
|
||||
token_count=sum(1 for token in tokens if _status(token) == "active"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Owner: the global switch and the per-organization switch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/admin/mcp", response_model=McpGlobalOut)
|
||||
async def get_mcp_state(
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> McpGlobalOut:
|
||||
"""Every organization with its MCP state and active token count."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.infrastructure.db.models import OrganizationRow
|
||||
|
||||
service = McpService(session)
|
||||
grants = build_model_grant_service(session)
|
||||
orgs = (
|
||||
(await session.execute(select(OrganizationRow).order_by(OrganizationRow.name)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
counts = await service.count_per_org()
|
||||
states = [
|
||||
McpOrgStateOut(
|
||||
organization_id=org.id,
|
||||
name=org.name,
|
||||
slug=org.slug,
|
||||
mcp_enabled=(await grants.get_org_policy(org.id)).mcp_enabled,
|
||||
token_count=counts.get(org.id, 0),
|
||||
)
|
||||
for org in orgs
|
||||
]
|
||||
return McpGlobalOut(
|
||||
gateway_enabled=settings.mcp_gateway_enabled,
|
||||
organizations=states,
|
||||
total_tokens=sum(counts.values()),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/admin/mcp")
|
||||
async def set_mcp_state(
|
||||
payload: McpGlobalIn,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
"""Disable (or re-enable) the gateway platform-wide.
|
||||
|
||||
The switch is a setting rather than a table: it must hold even before any
|
||||
token exists, and a gateway that cannot reach the database should not be the
|
||||
thing deciding it can serve traffic.
|
||||
"""
|
||||
settings.mcp_gateway_enabled = bool(payload.enabled)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MCP_GLOBAL_SWITCH,
|
||||
actor_user_id=owner.id,
|
||||
target_type="platform",
|
||||
target_id="mcp",
|
||||
metadata={"enabled": bool(payload.enabled)},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"gateway_enabled": settings.mcp_gateway_enabled}
|
||||
|
||||
|
||||
@router.put("/admin/mcp/organizations/{organization_id}")
|
||||
async def set_org_mcp_state(
|
||||
organization_id: str,
|
||||
payload: McpOrgSwitchIn,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
"""One organization's access to the gateway, independent of its tokens."""
|
||||
policy = await build_model_grant_service(session).set_mcp_enabled(
|
||||
organization_id=organization_id,
|
||||
enabled=bool(payload.enabled),
|
||||
actor_user_id=owner.id,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MCP_ORG_SWITCH,
|
||||
organization_id=organization_id,
|
||||
actor_user_id=owner.id,
|
||||
target_type="organization",
|
||||
target_id=organization_id,
|
||||
metadata={"enabled": policy.mcp_enabled},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"organization_id": organization_id, "mcp_enabled": policy.mcp_enabled}
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -2,10 +2,317 @@
|
||||
|
||||
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
|
||||
route its full path (no prefix is applied here besides ``/api``).
|
||||
|
||||
Trust ladder, matching the plan: a viewer reads, a member teaches, an admin edits
|
||||
or deletes anything. The same rules are enforced inside ``MemoryService`` too,
|
||||
so the MCP gateway (Phase 5) hits the same wall — a rule that only the router
|
||||
knows is a rule an agent can bypass.
|
||||
|
||||
Everything stored here went through ``redact`` on the way in, so no endpoint can
|
||||
ever return a credential: what you can read is what was safe to write.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.container_org import build_memory_service
|
||||
from app.application.ports.realtime import RealtimeEvent, org_channel, publish
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.memory_service import MemoryService
|
||||
from app.core.database import get_session
|
||||
from app.domain.identity.audit_actions import (
|
||||
MEMORY_CONFIRM,
|
||||
MEMORY_CREATE,
|
||||
MEMORY_DELETE,
|
||||
MEMORY_PIN,
|
||||
MEMORY_UPDATE,
|
||||
)
|
||||
from app.domain.identity.models import Role
|
||||
from app.domain.memory.models import (
|
||||
KIND_LABELS,
|
||||
SOURCE_LABELS,
|
||||
Memory,
|
||||
MemoryKind,
|
||||
)
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_org_context,
|
||||
require_role_at_least,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["memory"])
|
||||
|
||||
#: Anyone with a membership reads; teaching is member+; editing is admin+.
|
||||
_teacher = require_role_at_least(Role.MEMBER)
|
||||
_editor = require_role_at_least(Role.ADMIN)
|
||||
|
||||
|
||||
async def _service(session: AsyncSession = Depends(get_session)) -> MemoryService:
|
||||
return build_memory_service(session)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MemoryOut(BaseModel):
|
||||
id: str
|
||||
kind: str
|
||||
kind_label: str
|
||||
key: str | None = None
|
||||
content: str
|
||||
source: str
|
||||
source_label: str
|
||||
evidence_task_id: str | None = None
|
||||
pinned: bool
|
||||
confidence: float
|
||||
expires_at: str | None = None
|
||||
hits: int
|
||||
last_used_at: str | None = None
|
||||
created_by_user_id: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class MemoryListOut(BaseModel):
|
||||
organization_id: str
|
||||
items: list[MemoryOut]
|
||||
total: int
|
||||
pinned: int
|
||||
chars: int
|
||||
|
||||
|
||||
class MemoryCreateIn(BaseModel):
|
||||
kind: str = Field(default=str(MemoryKind.RULE), max_length=16)
|
||||
content: str = Field(min_length=2, max_length=1500)
|
||||
key: str | None = Field(default=None, max_length=80)
|
||||
|
||||
|
||||
class MemoryUpdateIn(BaseModel):
|
||||
content: str = Field(min_length=2, max_length=1500)
|
||||
key: str | None = Field(default=None, max_length=80)
|
||||
|
||||
|
||||
class MemoryPinIn(BaseModel):
|
||||
pinned: bool = True
|
||||
|
||||
|
||||
def _out(memory: Memory) -> MemoryOut:
|
||||
return MemoryOut(
|
||||
id=memory.id,
|
||||
kind=str(memory.kind),
|
||||
kind_label=KIND_LABELS.get(str(memory.kind), "یادداشت"),
|
||||
key=memory.key,
|
||||
content=memory.content,
|
||||
source=str(memory.source),
|
||||
source_label=SOURCE_LABELS.get(str(memory.source), ""),
|
||||
evidence_task_id=memory.evidence_task_id,
|
||||
pinned=memory.pinned,
|
||||
confidence=memory.confidence,
|
||||
expires_at=memory.expires_at.isoformat() if memory.expires_at else None,
|
||||
hits=memory.hits,
|
||||
last_used_at=memory.last_used_at.isoformat() if memory.last_used_at else None,
|
||||
created_by_user_id=memory.created_by_user_id,
|
||||
created_at=memory.created_at.isoformat() if memory.created_at else None,
|
||||
updated_at=memory.updated_at.isoformat() if memory.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
async def _audit(
|
||||
session: AsyncSession,
|
||||
request: Request,
|
||||
*,
|
||||
action: str,
|
||||
context: OrgContext,
|
||||
target_id: str,
|
||||
metadata: dict | None = None,
|
||||
) -> None:
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=action,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="memory",
|
||||
target_id=target_id,
|
||||
metadata=metadata or {},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
|
||||
|
||||
async def _announce(context: OrgContext, memory: Memory) -> None:
|
||||
"""Tell the tenant's open tabs that memory changed.
|
||||
|
||||
``memory.changed`` is published on the org channel and nowhere else: a
|
||||
memory belongs to one organization, and the realtime authorisation for
|
||||
``org:<id>`` is membership — exactly the same rule the endpoint enforced.
|
||||
A failed publish is a lost refresh, never a failed write.
|
||||
"""
|
||||
await publish(
|
||||
RealtimeEvent(
|
||||
type="memory.changed",
|
||||
channels=(org_channel(context.org_id),),
|
||||
organization_id=context.org_id,
|
||||
data={
|
||||
"memory_id": memory.id,
|
||||
"kind": str(memory.kind),
|
||||
"pinned": memory.pinned,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/organizations/{org_id}/memory", response_model=MemoryListOut)
|
||||
async def list_memory(
|
||||
kind: str | None = Query(default=None, max_length=16),
|
||||
q: str | None = Query(default="", max_length=120),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: MemoryService = Depends(_service),
|
||||
) -> MemoryListOut:
|
||||
"""The tab "قوانین و ترجیحات" and friends; also the source list for search.
|
||||
|
||||
Filtering by ``kind`` is a convenience, not a security boundary: an
|
||||
organization's whole memory is visible to any member by design.
|
||||
"""
|
||||
items = await service.list(organization_id=context.org_id, kind=kind, search=q)
|
||||
stats = await service.stats(context.org_id)
|
||||
return MemoryListOut(
|
||||
organization_id=context.org_id,
|
||||
items=[_out(m) for m in items],
|
||||
total=stats["total"],
|
||||
pinned=stats["pinned"],
|
||||
chars=stats["chars"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teach
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/organizations/{org_id}/memory",
|
||||
response_model=MemoryOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def teach_memory(
|
||||
payload: MemoryCreateIn,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_teacher),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MemoryOut:
|
||||
""""به سازمان یاد بده": a rule or a preference, stored redacted."""
|
||||
memory = await service.teach(
|
||||
organization_id=context.org_id,
|
||||
user_id=context.user.id,
|
||||
role=context.membership.role,
|
||||
kind=payload.kind,
|
||||
content=payload.content,
|
||||
key=payload.key,
|
||||
)
|
||||
await _audit(
|
||||
session,
|
||||
request,
|
||||
action=MEMORY_CREATE,
|
||||
context=context,
|
||||
target_id=memory.id,
|
||||
metadata={"kind": str(memory.kind), "key": memory.key},
|
||||
)
|
||||
await _announce(context, memory)
|
||||
return _out(memory)
|
||||
|
||||
|
||||
@router.patch("/organizations/{org_id}/memory/{memory_id}", response_model=MemoryOut)
|
||||
async def edit_memory(
|
||||
memory_id: str,
|
||||
payload: MemoryUpdateIn,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_editor),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MemoryOut:
|
||||
memory = await service.edit(
|
||||
organization_id=context.org_id,
|
||||
memory_id=memory_id,
|
||||
content=payload.content,
|
||||
key=payload.key,
|
||||
)
|
||||
await _audit(session, request, action=MEMORY_UPDATE, context=context, target_id=memory_id)
|
||||
await _announce(context, memory)
|
||||
return _out(memory)
|
||||
|
||||
|
||||
@router.post("/organizations/{org_id}/memory/{memory_id}/pin", response_model=MemoryOut)
|
||||
async def pin_memory(
|
||||
memory_id: str,
|
||||
payload: MemoryPinIn,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_editor),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MemoryOut:
|
||||
"""Pin → always in context and never evicted; unpin → back to search."""
|
||||
memory = await service.set_pinned(
|
||||
organization_id=context.org_id, memory_id=memory_id, pinned=payload.pinned
|
||||
)
|
||||
await _audit(
|
||||
session,
|
||||
request,
|
||||
action=MEMORY_PIN,
|
||||
context=context,
|
||||
target_id=memory_id,
|
||||
metadata={"pinned": payload.pinned},
|
||||
)
|
||||
await _announce(context, memory)
|
||||
return _out(memory)
|
||||
|
||||
|
||||
@router.post("/organizations/{org_id}/memory/{memory_id}/confirm", response_model=MemoryOut)
|
||||
async def confirm_memory(
|
||||
memory_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_editor),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MemoryOut:
|
||||
""""درس تأیید شد": the lesson becomes settled knowledge and stops expiring."""
|
||||
memory = await service.confirm(organization_id=context.org_id, memory_id=memory_id)
|
||||
await _audit(
|
||||
session, request, action=MEMORY_CONFIRM, context=context, target_id=memory_id
|
||||
)
|
||||
await _announce(context, memory)
|
||||
return _out(memory)
|
||||
|
||||
|
||||
@router.delete("/organizations/{org_id}/memory/{memory_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_memory(
|
||||
memory_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_editor),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
await service.delete(organization_id=context.org_id, memory_id=memory_id)
|
||||
await _audit(
|
||||
session, request, action=MEMORY_DELETE, context=context, target_id=memory_id
|
||||
)
|
||||
await publish(
|
||||
RealtimeEvent(
|
||||
type="memory.changed",
|
||||
channels=(org_channel(context.org_id),),
|
||||
organization_id=context.org_id,
|
||||
data={"memory_id": memory_id, "deleted": True},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1 +1,21 @@
|
||||
"""MCP gateway (owned by Agent 1)."""
|
||||
"""The MCP gateway package (owned by Agent 1, PLAN-v2 §6).
|
||||
|
||||
``gateway`` holds the tools and their rules; ``server`` binds them to the
|
||||
official SDK. ``python -m app.interfaces.mcp.server`` starts the gateway.
|
||||
"""
|
||||
|
||||
from app.interfaces.mcp.gateway import (
|
||||
TOOL_SPECS,
|
||||
McpCallResult,
|
||||
McpError,
|
||||
McpGateway,
|
||||
build_gateway,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TOOL_SPECS",
|
||||
"McpCallResult",
|
||||
"McpError",
|
||||
"McpGateway",
|
||||
"build_gateway",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
"""The MCP tools themselves — framework-free, so they can be called directly.
|
||||
|
||||
``app.interfaces.mcp.server`` adapts these to the official SDK; this module
|
||||
holds the actual work and is what the tests exercise. Four rules are enforced
|
||||
here, once, for every tool:
|
||||
|
||||
1. **The organization comes from the token, never from the arguments.** A tool
|
||||
body receives an authenticated ``TokenIdentity`` and uses its
|
||||
``organization_id``; an ``organization_id`` in the arguments is ignored, so an
|
||||
agent cannot ask about a tenant it was not issued for.
|
||||
2. **A scope is checked before the work starts.** Not after.
|
||||
3. **Every call is audited** (``mcp.tool_call`` with the token prefix, the tool,
|
||||
and whether it succeeded), rate-limited, and its output redacted.
|
||||
4. **An error is an answer, not a crash**: the agent gets a plain message, and
|
||||
the exception never reaches the transport.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.application.container_org import (
|
||||
build_memory_service,
|
||||
build_model_grant_service,
|
||||
)
|
||||
from app.application.services.mcp_service import McpService, TokenIdentity
|
||||
from app.core.config import settings
|
||||
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
|
||||
from app.core.redact import redact
|
||||
from app.domain.memory.models import MemoryKind
|
||||
from app.infrastructure.error_log import record_error
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ToolSpec:
|
||||
"""One tool: its name, the scope it needs, and its JSON schema."""
|
||||
name: str
|
||||
scope: str
|
||||
description: str
|
||||
parameters: dict[str, Any]
|
||||
|
||||
|
||||
TOOL_SPECS: tuple[ToolSpec, ...] = (
|
||||
ToolSpec(
|
||||
name="app_get_profile",
|
||||
scope="app:read",
|
||||
description="پروفایل اپلیکیشن این سازمان: نام، نوع، زبان، فریمورک، "
|
||||
"دستورهای ساخت و تست، پورت و مسیر سلامت.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="tasks_list",
|
||||
scope="tasks:read",
|
||||
description="فهرست کارهای این سازمان، به ترتیب جدیدترین.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "وضعیت اختیاری برای فیلتر (new، running، done، …)",
|
||||
},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="tasks_get",
|
||||
scope="tasks:read",
|
||||
description="یک کار بر اساس شناسهٔ آن، در همین سازمان.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {"type": "string", "description": "شناسهٔ کار"},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="tasks_create",
|
||||
scope="tasks:write",
|
||||
description="ثبت یک کار تازه برای مدیر سازمان؛ مدیر خودش آن را تقسیم "
|
||||
"و به کارمندان میسپارد.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "عنوان کوتاه کار"},
|
||||
"description": {"type": "string", "description": "شرح کامل درخواست"},
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="memory_search",
|
||||
scope="memory:read",
|
||||
description="جستوجو در حافظهٔ سازمان: قوانین، ترجیحات، درسها و نقشهٔ کد.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "واژههای جستوجو"},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"description": "نوع اختیاری: rule، preference، lesson، fact، code_map",
|
||||
},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="memory_write",
|
||||
scope="memory:write",
|
||||
description="یادآوری یک واقعیت یا ترجیح به سازمان. گذرواژه و کلید "
|
||||
"هرگز ذخیره نمیشود.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["fact", "preference"],
|
||||
"description": "فقط «fact» یا «preference»",
|
||||
},
|
||||
"content": {"type": "string", "description": "یک جملهٔ کوتاه"},
|
||||
},
|
||||
"required": ["kind", "content"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="knowledge_search",
|
||||
scope="memory:read",
|
||||
description="جستوجو در منابع دانش سازمان (اسناد بلند).",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "واژههای جستوجو"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 5},
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="pipeline_status",
|
||||
scope="delivery:read",
|
||||
description="وضعیت خط تحویل (استقرار و دمو). در این نسخه در دسترس نیست.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
#: The only kinds an external agent may write. A rule is the organization's own
|
||||
#: to set through the UI, and a lesson is the engine's to learn.
|
||||
MCP_WRITABLE_KINDS = frozenset({MemoryKind.FACT, MemoryKind.PREFERENCE})
|
||||
|
||||
|
||||
class McpError(Exception):
|
||||
"""A refusal the agent can read. Never carries a secret."""
|
||||
|
||||
def __init__(self, message: str, *, code: str = "mcp_error") -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class McpCallResult:
|
||||
"""What one tool call produced: a redacted payload, or a readable refusal."""
|
||||
|
||||
ok: bool
|
||||
payload: dict[str, Any]
|
||||
error: str | None = None
|
||||
code: str = "mcp_error"
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
if self.ok:
|
||||
return self.payload
|
||||
return {
|
||||
"error": {"code": self.code, "message": self.error or "عملیات ناموفق بود."}
|
||||
}
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""The JSON text an agent receives. Always redacted, always short."""
|
||||
return json.dumps(redact(self.as_dict()), ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
class McpGateway:
|
||||
"""Executes the tools for one authenticated token.
|
||||
|
||||
``session_factory`` is injectable so the tests can point the gateway at their
|
||||
in-memory database; production passes nothing and gets the application's own
|
||||
factory, exactly like the workers.
|
||||
"""
|
||||
|
||||
def __init__(self, *, session_factory=None, rate_limiter=None) -> None:
|
||||
from app.application.services.mcp_service import TokenRateLimiter
|
||||
|
||||
self._session_factory = session_factory
|
||||
self._limiter = rate_limiter or TokenRateLimiter()
|
||||
|
||||
# -- public API ----------------------------------------------------------
|
||||
|
||||
async def call(
|
||||
self,
|
||||
*,
|
||||
tool: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
token: str,
|
||||
identity: TokenIdentity | None = None,
|
||||
) -> McpCallResult:
|
||||
"""Run one tool for one token. Every failure mode is an answer."""
|
||||
spec = _SPECS_BY_NAME.get(tool)
|
||||
if spec is None:
|
||||
return McpCallResult(ok=False, payload={}, error=f"ابزار ناشناخته: {tool}")
|
||||
args = arguments or {}
|
||||
if not settings.mcp_gateway_enabled:
|
||||
# The owner's kill switch. Nothing is attributable yet, so this lands
|
||||
# in the error ledger rather than in one organization's audit trail.
|
||||
await _note("mcp.gateway_disabled", f"tool={spec.name}")
|
||||
return self._fail(
|
||||
"دسترسی MCP از سوی مالک سامانه غیرفعال شده است.",
|
||||
code="mcp_disabled",
|
||||
)
|
||||
|
||||
factory = self._session_factory or _default_factory()
|
||||
async with factory() as session:
|
||||
service = McpService(session)
|
||||
identity = identity or await service.verify(token)
|
||||
if identity is None:
|
||||
# A token that does not exist, was revoked, or expired is one
|
||||
# answer — and it is unattributable, so it goes to the error
|
||||
# ledger where a human can see the pattern.
|
||||
await _note("mcp.invalid_token", f"tool={spec.name}")
|
||||
return self._fail(
|
||||
"توکن نامعتبر یا منقضی است.", code="invalid_token"
|
||||
)
|
||||
|
||||
async def refuse(message: str, code: str) -> McpCallResult:
|
||||
"""Every refusal from here on is audited against this organization.
|
||||
|
||||
The audit row is committed inside the refusal: a denial nobody can
|
||||
see afterwards is not an audit trail, it is a hope.
|
||||
"""
|
||||
await self._audit(
|
||||
session, identity=identity, spec=spec, ok=False, error=message
|
||||
)
|
||||
await session.commit()
|
||||
return McpCallResult(ok=False, payload={}, error=message, code=code)
|
||||
|
||||
if not identity.allows(spec.scope):
|
||||
return await refuse(
|
||||
"این توکن به این ابزار دسترسی ندارد.", "scope_denied"
|
||||
)
|
||||
policy = await build_model_grant_service(session).get_org_policy(
|
||||
identity.organization_id
|
||||
)
|
||||
if not policy.mcp_enabled:
|
||||
return await refuse(
|
||||
"دسترسی MCP این سازمان غیرفعال است.", "org_disabled"
|
||||
)
|
||||
if not self._limiter.allow(identity.token_id):
|
||||
return await refuse(
|
||||
"تعداد درخواستها بیش از حد مجاز است؛ کمی بعد تلاش کنید.",
|
||||
"rate_limited",
|
||||
)
|
||||
|
||||
handler = _HANDLERS[spec.name]
|
||||
try:
|
||||
payload = await handler(self, session=session, identity=identity, **args)
|
||||
except (NotFoundError, PermissionDeniedError, ValidationError, McpError) as exc:
|
||||
message = str(getattr(exc, "message", exc))
|
||||
await self._audit(
|
||||
session, identity=identity, spec=spec, ok=False, error=message
|
||||
)
|
||||
await session.commit()
|
||||
return McpCallResult(ok=False, payload={}, error=message, code="tool_failed")
|
||||
except TypeError:
|
||||
# An argument shape the tool did not expect: the agent's fault,
|
||||
# not the platform's.
|
||||
return await refuse(
|
||||
"آرگومانهای ابزار نامعتبر است.", "bad_arguments"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - an outage is an answer too
|
||||
# Anything else is infrastructure, and its message may contain a
|
||||
# DSN or a heap address. The agent gets a plain sentence; the
|
||||
# operator gets the real error in the error console.
|
||||
await record_error(
|
||||
kind="mcp.tool_crashed",
|
||||
message=str(exc),
|
||||
exc=exc,
|
||||
organization_id=identity.organization_id,
|
||||
metadata={"tool": spec.name, "prefix": identity.prefix},
|
||||
)
|
||||
await self._audit(
|
||||
session, identity=identity, spec=spec, ok=False, error="internal"
|
||||
)
|
||||
await session.commit()
|
||||
return self._fail(
|
||||
"خطای داخلی سامانه؛ این خطا ثبت شد.", code="internal_error"
|
||||
)
|
||||
await service.touch(identity.token_id)
|
||||
await self._audit(session, identity=identity, spec=spec, ok=True)
|
||||
await session.commit()
|
||||
return McpCallResult(ok=True, payload=payload)
|
||||
|
||||
# -- helpers -------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _fail(message: str, *, code: str) -> McpCallResult:
|
||||
"""A refusal is a normal answer: the agent gets a message it can act on."""
|
||||
return McpCallResult(ok=False, payload={}, error=message, code=code)
|
||||
|
||||
async def _audit(
|
||||
self,
|
||||
session,
|
||||
*,
|
||||
identity: TokenIdentity,
|
||||
spec: ToolSpec,
|
||||
ok: bool,
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""One audit row per call. Never allowed to fail the call itself."""
|
||||
from app.application.services.audit_service import record_audit
|
||||
|
||||
try:
|
||||
await record_audit(
|
||||
session,
|
||||
action="mcp.tool_call",
|
||||
organization_id=identity.organization_id,
|
||||
actor_user_id=None,
|
||||
target_type="mcp_tool",
|
||||
target_id=spec.name,
|
||||
metadata={
|
||||
"prefix": identity.prefix,
|
||||
"scope": spec.scope,
|
||||
"ok": ok,
|
||||
"error": error[:200],
|
||||
},
|
||||
ip="mcp",
|
||||
user_agent=f"mcp/{identity.prefix}",
|
||||
)
|
||||
except Exception: # noqa: BLE001 - auditing must not fail a tool call
|
||||
return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool bodies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _app_profile(
|
||||
gateway: McpGateway, *, session, identity: TokenIdentity, **_ignored: Any
|
||||
) -> dict[str, Any]:
|
||||
from app.infrastructure.repositories.app_profile import AppProfileRepository
|
||||
from app.infrastructure.repositories.projects import ProjectRepository
|
||||
|
||||
profile = await AppProfileRepository(session).get_for_org(identity.organization_id)
|
||||
if profile is None:
|
||||
raise NotFoundError("اپلیکیشنی برای این سازمان یافت نشد.")
|
||||
project = await ProjectRepository(session).get(profile.project_id)
|
||||
return {
|
||||
"project_name": project.name if project else "",
|
||||
"project_slug": project.slug if project else "",
|
||||
"app_type": profile.app_type,
|
||||
"language": profile.language,
|
||||
"framework": profile.framework,
|
||||
"runtime_version": profile.runtime_version,
|
||||
"package_manager": profile.package_manager,
|
||||
"setup_command": profile.setup_command,
|
||||
"build_command": profile.build_command,
|
||||
"test_command": profile.test_command,
|
||||
"start_command": profile.start_command,
|
||||
"app_port": profile.app_port,
|
||||
"healthcheck_path": profile.healthcheck_path,
|
||||
"default_branch": profile.default_branch,
|
||||
"git_source": profile.git_source,
|
||||
"repo_url": profile.external_repo_url,
|
||||
}
|
||||
|
||||
|
||||
async def _tasks_list(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
status: str | None = None,
|
||||
limit: int = 25,
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
from app.application.container import build_workforce_service
|
||||
from app.domain.workforce.models import TaskStatus
|
||||
|
||||
service = build_workforce_service(session)
|
||||
parsed: TaskStatus | None = None
|
||||
if status:
|
||||
try:
|
||||
parsed = TaskStatus(str(status).strip().lower())
|
||||
except ValueError as exc:
|
||||
raise ValidationError("وضعیت کار نامعتبر است.") from exc
|
||||
tasks = await service.list_tasks(
|
||||
identity.organization_id, status=parsed, limit=max(1, min(int(limit), 100))
|
||||
)
|
||||
return {
|
||||
"tasks": [
|
||||
{
|
||||
"id": task.id,
|
||||
"title": task.title,
|
||||
"status": str(task.status),
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
"assigned_employee_id": task.assigned_employee_id,
|
||||
}
|
||||
for task in tasks
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def _tasks_get(
|
||||
gateway: McpGateway, *, session, identity: TokenIdentity, task_id: str, **_ignored: Any
|
||||
) -> dict[str, Any]:
|
||||
from app.application.container import build_workforce_service
|
||||
|
||||
if not task_id:
|
||||
raise ValidationError("شناسهٔ کار لازم است.")
|
||||
service = build_workforce_service(session)
|
||||
task = await service.get_task_or_404(
|
||||
organization_id=identity.organization_id, task_id=task_id
|
||||
)
|
||||
return {
|
||||
"task": {
|
||||
"id": task.id,
|
||||
"title": task.title,
|
||||
"description": task.description,
|
||||
"status": str(task.status),
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
"project_id": task.project_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def _tasks_create(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
title: str,
|
||||
description: str = "",
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""A user order to the manager: the current public signature, untouched."""
|
||||
from app.application.container import build_workforce_service
|
||||
|
||||
if not title or not title.strip():
|
||||
raise ValidationError("عنوان کار لازم است.")
|
||||
creator = await McpService(session).creator_of(identity.token_id)
|
||||
service = build_workforce_service(session)
|
||||
task = await service.create_task(
|
||||
organization_id=identity.organization_id,
|
||||
created_by_user_id=creator or "",
|
||||
title=title,
|
||||
description=description,
|
||||
)
|
||||
return {"task": {"id": task.id, "title": task.title, "status": str(task.status)}}
|
||||
|
||||
|
||||
async def _memory_search(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
query: str = "",
|
||||
kind: str | None = None,
|
||||
limit: int = 10,
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
service = build_memory_service(session)
|
||||
items = await service.list(
|
||||
identity.organization_id, kind=kind, search=query or None
|
||||
)
|
||||
return {
|
||||
"memories": [
|
||||
{
|
||||
"id": memory.id,
|
||||
"kind": str(memory.kind),
|
||||
"key": memory.key,
|
||||
"content": memory.content,
|
||||
"pinned": memory.pinned,
|
||||
"confidence": memory.confidence,
|
||||
}
|
||||
for memory in items[: max(1, min(int(limit), 50))]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def _memory_write(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
kind: str,
|
||||
content: str,
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
from app.domain.memory.models import coerce_kind
|
||||
|
||||
entry_kind = coerce_kind(kind)
|
||||
if entry_kind not in MCP_WRITABLE_KINDS:
|
||||
raise McpError("با توکن فقط «fact» یا «preference» قابل نوشتن است.")
|
||||
service = build_memory_service(session)
|
||||
memory_id = await service.record(
|
||||
organization_id=identity.organization_id,
|
||||
kind=str(entry_kind),
|
||||
content=content,
|
||||
source="user",
|
||||
)
|
||||
if memory_id is None: # pragma: no cover - the task cap cannot apply here
|
||||
raise McpError("یادداشت ذخیره نشد.")
|
||||
return {"memory": {"id": memory_id, "kind": str(entry_kind)}}
|
||||
|
||||
|
||||
async def _knowledge_search(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
from app.application.container import build_knowledge_service
|
||||
|
||||
if not query:
|
||||
raise ValidationError("عبارت جستوجو لازم است.")
|
||||
service = build_knowledge_service(session)
|
||||
hits = await service.search(
|
||||
organization_id=identity.organization_id,
|
||||
query=query,
|
||||
limit=max(1, min(int(limit), 20)),
|
||||
)
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"content": hit.get("content", ""),
|
||||
"source_name": hit.get("source_name", ""),
|
||||
"document_title": hit.get("document_title", ""),
|
||||
}
|
||||
for hit in hits
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def _pipeline_status(
|
||||
gateway: McpGateway, *, session, identity: TokenIdentity, **_ignored: Any
|
||||
) -> dict[str, Any]:
|
||||
"""Delivery is another workstream's table; the tool exists and says so."""
|
||||
return {"available": False, "message": "خط تحویل در این نسخه در دسترس نیست."}
|
||||
|
||||
|
||||
_HANDLERS: dict[str, Callable[..., Awaitable[dict[str, Any]]]] = {
|
||||
"app_get_profile": _app_profile,
|
||||
"tasks_list": _tasks_list,
|
||||
"tasks_get": _tasks_get,
|
||||
"tasks_create": _tasks_create,
|
||||
"memory_search": _memory_search,
|
||||
"memory_write": _memory_write,
|
||||
"knowledge_search": _knowledge_search,
|
||||
"pipeline_status": _pipeline_status,
|
||||
}
|
||||
|
||||
_SPECS_BY_NAME: dict[str, ToolSpec] = {spec.name: spec for spec in TOOL_SPECS}
|
||||
|
||||
|
||||
def _default_factory():
|
||||
from app.core.database import SessionFactory
|
||||
|
||||
return SessionFactory
|
||||
|
||||
|
||||
async def _note(kind: str, detail: str) -> None:
|
||||
"""Record an unattributable attempt in the error ledger, best-effort.
|
||||
|
||||
A bad token and a disabled gateway happen before the caller is known, so they
|
||||
belong to nobody's audit trail — but they are exactly the patterns an operator
|
||||
needs to see, which is what the error console is for.
|
||||
"""
|
||||
from app.infrastructure.error_log import record_error
|
||||
|
||||
try:
|
||||
await record_error(kind=kind, message=detail, source="mcp")
|
||||
except Exception: # noqa: BLE001 - telemetry must never fail a call
|
||||
return
|
||||
|
||||
|
||||
def build_gateway(*, session_factory=None) -> McpGateway:
|
||||
"""The gateway the server and the tests both use.
|
||||
|
||||
Each gateway owns its own in-memory rate limiter: one process, one budget.
|
||||
"""
|
||||
return McpGateway(session_factory=session_factory)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MCP_WRITABLE_KINDS",
|
||||
"TOOL_SPECS",
|
||||
"McpCallResult",
|
||||
"McpError",
|
||||
"McpGateway",
|
||||
"build_gateway",
|
||||
]
|
||||
@@ -1,14 +1,178 @@
|
||||
"""MCP gateway entry point (owned by Agent 1). Foundation stub."""
|
||||
"""The MCP gateway server: the official SDK, bound to our tools.
|
||||
|
||||
This module is the *adapter*, not the logic. The tools live in
|
||||
``app.interfaces.mcp.gateway`` (framework-free and directly testable); what
|
||||
happens here is transport only:
|
||||
|
||||
* a ``MCPServer`` (streamable HTTP) with the eight tools registered, each with
|
||||
the argument schema the gateway validates;
|
||||
* every call resolves the caller's token from the ``Authorization: Bearer``
|
||||
header — never from the arguments — and hands it to the gateway;
|
||||
* a refusal raises ``ToolError``, which the transport reports as an ``isError``
|
||||
tool result: an agent sees "this token does not have that scope" instead of a
|
||||
stack trace;
|
||||
* ``main()`` binds ``MCP_GATEWAY_HOST:MCP_GATEWAY_PORT`` — what ``pnpm mcp:dev``
|
||||
runs. Putting Caddy in front of it is the owner's job (see
|
||||
``services/mcp-gateway/README.md``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
def main() -> None:
|
||||
raise SystemExit(
|
||||
"MCP gateway is not implemented yet "
|
||||
"(see docs/plan/TODO-agent-1-org-platform.md, Phase 5)"
|
||||
from mcp.server.mcpserver.context import Context
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.redact import redact
|
||||
from app.interfaces.mcp.gateway import McpCallResult, McpGateway, build_gateway
|
||||
|
||||
#: How a client authenticates. One header, one token, no query parameter: a
|
||||
#: token in a URL ends up in proxy logs and browser history.
|
||||
BEARER_PREFIX = "bearer "
|
||||
|
||||
|
||||
def _bearer_token(ctx: Any) -> str:
|
||||
"""The bearer token of this request, or an empty string.
|
||||
|
||||
``ctx.headers`` raises when there is no request at all (a stdio transport, or
|
||||
a direct call in a test), so the read is defensive by design: no request
|
||||
means no token, which means no tool runs.
|
||||
"""
|
||||
try:
|
||||
headers = getattr(ctx, "headers", None) if ctx is not None else None
|
||||
except ValueError: # pragma: no cover - "Context not available"
|
||||
return ""
|
||||
if not headers:
|
||||
return ""
|
||||
raw = headers.get("authorization") or headers.get("Authorization") or ""
|
||||
if isinstance(raw, bytes): # pragma: no cover - transports hand us str
|
||||
raw = raw.decode("utf-8", "ignore")
|
||||
if raw.lower().startswith(BEARER_PREFIX):
|
||||
return raw[len(BEARER_PREFIX) :].strip()
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def _build_server(gateway: McpGateway) -> Any:
|
||||
"""Build the SDK server with our eight tools registered."""
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
server: Any = MCPServer(
|
||||
name="smart-org-gateway",
|
||||
version="1.0.0",
|
||||
instructions=(
|
||||
"دسترسی به یک سازمان در سامانه سازمان هوشمند. سازمان از توکن خوانده "
|
||||
"میشود، نه از آرگومانها؛ توکنی که محدودهٔ لازم را نداشته باشد رد "
|
||||
"میشود."
|
||||
),
|
||||
)
|
||||
|
||||
async def _dispatch(tool: str, ctx: Any, **arguments: Any) -> dict[str, Any]:
|
||||
result: McpCallResult = await gateway.call(
|
||||
tool=tool, arguments=arguments, token=_bearer_token(ctx)
|
||||
)
|
||||
if not result.ok:
|
||||
raise ToolError(result.error or "عملیات ناموفق بود.")
|
||||
# Redacted on the way out as well as on the way in: a tool may return a
|
||||
# stored value that predates a redaction rule, and the agent is outside
|
||||
# the platform's trust boundary.
|
||||
payload: dict[str, Any] = redact(result.payload)
|
||||
return payload
|
||||
|
||||
async def app_get_profile(ctx: Context | None = None) -> dict[str, Any]:
|
||||
"""پروفایل اپلیکیشن سازمان: نام، نوع، زبان، فریمورک، دستورها، پورت."""
|
||||
return await _dispatch("app_get_profile", ctx)
|
||||
|
||||
async def tasks_list(
|
||||
status: str | None = None, limit: int = 25, ctx: Context | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""فهرست کارهای سازمان، به ترتیب جدیدترین."""
|
||||
return await _dispatch("tasks_list", ctx, status=status, limit=limit)
|
||||
|
||||
async def tasks_get(task_id: str, ctx: Context | None = None) -> dict[str, Any]:
|
||||
"""یک کار بر اساس شناسهٔ آن."""
|
||||
return await _dispatch("tasks_get", ctx, task_id=task_id)
|
||||
|
||||
async def tasks_create(
|
||||
title: str, description: str = "", ctx: Context | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""ثبت یک کار تازه برای مدیر سازمان."""
|
||||
return await _dispatch("tasks_create", ctx, title=title, description=description)
|
||||
|
||||
async def memory_search(
|
||||
query: str = "", kind: str | None = None, limit: int = 10,
|
||||
ctx: Context | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""جستوجو در حافظهٔ سازمان: قوانین، ترجیحات، درسها و نقشهٔ کد."""
|
||||
return await _dispatch(
|
||||
"memory_search", ctx, query=query, kind=kind, limit=limit
|
||||
)
|
||||
|
||||
async def memory_write(
|
||||
kind: str, content: str, ctx: Context | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""یادآوری یک واقعیت یا ترجیح به سازمان."""
|
||||
return await _dispatch("memory_write", ctx, kind=kind, content=content)
|
||||
|
||||
async def knowledge_search(
|
||||
query: str, limit: int = 5, ctx: Context | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""جستوجو در منابع دانش سازمان."""
|
||||
return await _dispatch("knowledge_search", ctx, query=query, limit=limit)
|
||||
|
||||
async def pipeline_status(ctx: Context | None = None) -> dict[str, Any]:
|
||||
"""وضعیت خط تحویل (استقرار و دمو)."""
|
||||
return await _dispatch("pipeline_status", ctx)
|
||||
|
||||
for tool in (
|
||||
app_get_profile,
|
||||
tasks_list,
|
||||
tasks_get,
|
||||
tasks_create,
|
||||
memory_search,
|
||||
memory_write,
|
||||
knowledge_search,
|
||||
pipeline_status,
|
||||
):
|
||||
server.add_tool(
|
||||
tool, name=tool.__name__, description=(tool.__doc__ or "").strip()
|
||||
)
|
||||
return server
|
||||
|
||||
|
||||
def build_app(gateway: McpGateway | None = None) -> Any:
|
||||
"""The ASGI app (streamable HTTP) for a uvicorn-style deployment."""
|
||||
server = _build_server(gateway or build_gateway())
|
||||
return server.streamable_http_app(
|
||||
streamable_http_path="/mcp", host=settings.mcp_gateway_host
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
"""Run the gateway: ``python -m app.interfaces.mcp.server``."""
|
||||
import sys
|
||||
|
||||
import uvicorn
|
||||
|
||||
host, port = settings.mcp_gateway_host, settings.mcp_gateway_port
|
||||
if not settings.mcp_gateway_enabled:
|
||||
# Starting anyway is right: the switch is the owner's, and the process
|
||||
# answering "disabled" is more diagnosable than a missing port.
|
||||
print(
|
||||
"mcp gateway: MCP_GATEWAY_ENABLED=false — every call will be refused",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
print(f"mcp gateway: http://{host}:{port}/mcp", file=sys.stderr, flush=True)
|
||||
uvicorn.run(
|
||||
_build_server(build_gateway()).streamable_http_app(
|
||||
streamable_http_path="/mcp", host=host
|
||||
),
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="info",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - the entry point
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user