Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ jobs:
run: >-
python -m pytest
tests/mcp/agentconfig_core
tests/mcp/test_import_isolation.py
tests/scripts/test_mcp_config.py
tests/scripts/test_auth.py
-q
Expand Down Expand Up @@ -163,9 +164,7 @@ jobs:
- name: Run Org Announcements tests
run: >-
python -m pytest
tests/mcp/agentconfig_org_announcements/test_authoring_client.py
tests/mcp/agentconfig_org_announcements/test_drafts.py
tests/mcp/agentconfig_org_announcements/test_graph_directory.py
tests/mcp/agentconfig_org_announcements

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Walkthrough — Include the runtime checks in CI.

The announcements job now selects the whole feature test directory instead of naming three individual files. That includes the new protocol, client-lifecycle, and telemetry tests alongside the earlier client, draft, and directory tests, without adding a workflow entry for each file. The foundation job also gains test_import_isolation.py, which checks that the sibling MCP providers load their own modules. These changes expand test selection; they do not start or deploy the announcements service.

-q

flightcheck-tests:
Expand Down
24 changes: 12 additions & 12 deletions solutions/ess-maker-skills/src/mcp/agentconfig_core/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ class _CredentialFailure(Enum):
)


class _LocalCredentialError(ValueError):
class LocalCredentialError(ValueError):
"""Local validation failure with an allowlisted renewal diagnostic."""

def __init__(self, message: str, reason: _CredentialFailure):
Expand All @@ -121,18 +121,18 @@ def _resolve_token() -> str:

def _read_token_file(token_file: str) -> str:
if not os.path.isfile(token_file):
raise _LocalCredentialError(
raise LocalCredentialError(
"AGENTCONFIG_ACCESS_TOKEN_FILE does not exist", _CredentialFailure.FILE_MISSING
)
try:
with open(token_file, "r", encoding="utf-8") as handle:
token = handle.read().strip()
except (OSError, UnicodeError) as error:
raise _LocalCredentialError(
raise LocalCredentialError(
"AGENTCONFIG_ACCESS_TOKEN_FILE could not be read", _CredentialFailure.FILE_UNREADABLE
) from error
if not token:
raise _LocalCredentialError(
raise LocalCredentialError(
"AGENTCONFIG_ACCESS_TOKEN_FILE is empty", _CredentialFailure.FILE_EMPTY
)
return token
Expand All @@ -143,7 +143,7 @@ def _validate_token_tenant(token: str, expected_tenant_id: str | None) -> str:
expected_tenant_id is not None
and _decode_tenant_id_from_jwt(token) != expected_tenant_id
):
raise _LocalCredentialError(
raise LocalCredentialError(
"The AgentConfiguration token tenant does not match the configured "
"Dataverse environment. Sign in to that tenant or provide a matching token.",
_CredentialFailure.TENANT_MISMATCH,
Expand Down Expand Up @@ -292,7 +292,7 @@ def _decode_jwt_payload(token: str) -> dict[str, Any]:
"""
parts = token.split(".")
if len(parts) != 3:
raise _LocalCredentialError(
raise LocalCredentialError(
"AGENTCONFIG_ACCESS_TOKEN does not look like a JWT "
"(expected three dot-separated segments)",
_CredentialFailure.INVALID_TOKEN,
Expand All @@ -302,12 +302,12 @@ def _decode_jwt_payload(token: str) -> dict[str, Any]:
try:
payload = json.loads(base64.urlsafe_b64decode(padded))
except ValueError as error:
raise _LocalCredentialError(
raise LocalCredentialError(
"Could not decode AGENTCONFIG_ACCESS_TOKEN payload",
_CredentialFailure.INVALID_TOKEN,
) from error
if not isinstance(payload, dict):
raise _LocalCredentialError(
raise LocalCredentialError(
"AGENTCONFIG_ACCESS_TOKEN payload must be an object", _CredentialFailure.INVALID_TOKEN
)
return payload
Expand All @@ -317,13 +317,13 @@ def _decode_tenant_id_from_jwt(token: str) -> str:
"""Decode and validate the tenant ID (``tid``) used to address the route."""
tenant_id = _decode_jwt_payload(token).get("tid")
if not isinstance(tenant_id, str) or not tenant_id:
raise _LocalCredentialError(
raise LocalCredentialError(
"AGENTCONFIG_ACCESS_TOKEN payload has no 'tid' claim", _CredentialFailure.INVALID_TOKEN
)
try:
return str(uuid.UUID(tenant_id))
except ValueError as error:
raise _LocalCredentialError(
raise LocalCredentialError(
"AGENTCONFIG_ACCESS_TOKEN payload has an invalid 'tid' claim", _CredentialFailure.INVALID_TOKEN
) from error

Expand Down Expand Up @@ -352,7 +352,7 @@ def validate_token_identity(token: str, tenant_id: str, object_id: str | None) -
raise ValueError("The authoring account cannot be identified without an oid claim")
actual = _decode_object_id_from_jwt(token)
if actual is None or actual.casefold() != object_id.casefold():
raise _LocalCredentialError(
raise LocalCredentialError(
"The token does not identify the intended authoring account", _CredentialFailure.ACCOUNT_MISMATCH
)
return token
Expand Down Expand Up @@ -467,7 +467,7 @@ async def _refresh_token(self, rejected_token: str) -> None:
"Could not renew credentials for the original account. "
"Provide matching credentials and retry."
)
if isinstance(error, _LocalCredentialError):
if isinstance(error, LocalCredentialError):
message = error.reason.value
elif isinstance(error, LockException):
message = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@
from _odata import ( # noqa: E402
_require_odata_id,
_validate_https_base_url,
_validate_title_id,
)
from agent_discovery import AgentDiscoveryClient # noqa: E402
from base_client import AgentConfigApiError # noqa: E402
from validation import validate_bulletin_id, validate_title_id # noqa: E402


DEFAULT_ORG_ANNOUNCEMENTS_BASE_URL = "https://substrate.office.com/weveb2/api/v1.1"
Expand All @@ -50,9 +50,6 @@
# truncated; it does not reveal an exact archived total.
ARCHIVED_WINDOW_SIZE = 50

_MAX_BULLETIN_ID_LENGTH = 256


class IndeterminateWriteError(AgentConfigApiError):
"""An unkeyed create may have committed but was never acknowledged.

Expand Down Expand Up @@ -83,30 +80,6 @@ def __init__(self, errors: list[dict[str, Any]]):
self.errors = errors


def _validate_bulletin_id(bulletin_id: str) -> str:
"""Validate a path-bound bulletin ID.

The ID is a backend-assigned opaque identifier, so this rejects anything
that could reshape the route (empty, padded, control characters, or a path
separator) rather than trying to canonicalize it.
"""
if not isinstance(bulletin_id, str) or not bulletin_id:
raise ValueError("bulletinId must be a non-empty string")
if bulletin_id != bulletin_id.strip():
raise ValueError("bulletinId must not have surrounding whitespace")
if len(bulletin_id) > _MAX_BULLETIN_ID_LENGTH:
raise ValueError(
f"bulletinId must not exceed {_MAX_BULLETIN_ID_LENGTH} characters"
)
if any(
ord(character) < 0x20 or ord(character) == 0x7F for character in bulletin_id
):
raise ValueError("bulletinId must not contain control characters")
if "/" in bulletin_id or "\\" in bulletin_id or "?" in bulletin_id:
raise ValueError("bulletinId must not contain path or query separators")
return bulletin_id


def _parse_instant(value: Any) -> Optional[datetime]:
"""Parse a UTC ISO instant, returning ``None`` for absent or unparseable text.

Expand Down Expand Up @@ -182,7 +155,7 @@ def __init__(self, *, transport: Optional[httpx.AsyncBaseTransport] = None):
)

def _collection_path(self, title_id: str) -> str:
encoded = _require_odata_id(_validate_title_id(title_id), "titleId")
encoded = _require_odata_id(validate_title_id(title_id), "titleId")
return f"tenants('{self.tenant_id}')/EmployeeAgents('{encoded}')/essbulletins"

@staticmethod
Expand Down Expand Up @@ -343,7 +316,7 @@ async def list_bulletins(self, title_id: str) -> list[dict[str, Any]]:

async def get_bulletin(self, title_id: str, bulletin_id: str) -> dict[str, Any]:
"""Load one canonical stored configuration."""
path = f"{self._collection_path(title_id)}/{_validate_bulletin_id(bulletin_id)}"
path = f"{self._collection_path(title_id)}/{validate_bulletin_id(bulletin_id)}"
return self._require_config(
await self._request("GET", path, transform_payload=False), title_id
)
Expand Down Expand Up @@ -397,7 +370,7 @@ async def transition_bulletin(
save returns, so it is unwrapped identically — including the ID check
that proves the transition landed on the requested record.
"""
validated_id = _validate_bulletin_id(bulletin_id)
validated_id = validate_bulletin_id(bulletin_id)
return self._unwrap_save_result(
await self._request(
"POST",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def normalize_suggested_instant(value: str, *, boundary: str) -> str:
class StrictModel(BaseModel):
"""Reject any field the contract does not name."""

model_config = ConfigDict(extra="forbid")
model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)


class BulletinAction(StrictModel):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mcp>=1.29.0,<2.0.0
anyio>=4.0,<5.0
httpx>=0.27.0,<1.0
msal>=1.35.0
pydantic>=2.0,<3.0
Expand Down
Loading
Loading