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
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ The official Python SDK & CLI for [ModelScope Hub](https://modelscope.cn) — do
## News

**v0.4.0** (2026-09-01)
- **Feature**: full MCP/Studios OpenAPI coverage: Studio lists, variables and configuration options; hosted MCP discovery; protected visibility and runtime metadata; read-only tokens can log in and rejected writes name the required tier
- **Feature**: complete OpenAPI coverage for Agent-IDP, MCP, and Studios — Agent Ed25519 identities, OIDC discovery/JWKS and signed JWT issuance (`HubApi`, `ms-hub agent-idp`); Studio lists, variables and configuration options; hosted MCP discovery; protected visibility and runtime metadata; read-only tokens can log in and rejected writes name the required tier. Agent private JWKs are only written to an explicitly requested owner-only file.
- **Fix**: Studio compat calls no longer leak connection options or API tokens, or drop cover images; errors distinguish permission, quota and conflicts; anonymous Studio info and log pagination work
- **Enhance**: MCP verb negotiation and Studio-owner listings adapt to endpoint behaviour
- **Quality**: the vendored OpenAPI spec and operation registry flag unimplemented published MCP/Studios endpoints
- **Quality**: the vendored OpenAPI spec and operation registry flag unimplemented published Agent-IDP, MCP, and Studios endpoints

**v0.3.1** (2026-09-01)
- **Refactor**: upload environment variables now state their units and semantics; deprecated names remain supported with warnings
Expand Down Expand Up @@ -685,6 +685,32 @@ ms-hub agent upload -r user/my-agent --local-dir ./my-agent --dry-run

</details>

### `ms-hub agent-idp`

Agent-IDP manages an Agent's **identity and signing key**, not its repository files. Use `ms-hub agent` for raw Agent repository transfer; use `ms-hub agent-idp` to register an Ed25519 public key, inspect OIDC metadata, and issue a short-lived JWT.

```bash
# Private JWK storage is always explicit; the command prints only its public JWK.
ms-hub agent-idp keygen --private-key-out ./agent.jwk
ms-hub agent-idp create --agent-name my-agent --private-key-file ./agent.jwk
ms-hub agent-idp issue-token --agent-id agent_id:modelscope:agent_xxx --audience my-hub --private-key-file ./agent.jwk

# These discovery endpoints are public and do not require login.
ms-hub agent-idp configuration
ms-hub agent-idp jwks
```

Protect the private JWK file: it is created with mode `0600` on POSIX, never copied into the SDK configuration or cache, and must not be committed. External key stores can pass a public JWK with `--public-jwk-file` for registration or rotation.

```python
from modelscope_hub import HubApi, generate_agent_key_pair

api = HubApi(token="ms-write-token")
private_jwk, public_jwk = generate_agent_key_pair()
identity = api.create_agent_identity({"agent_name": "my-agent", "public_key": public_jwk.to_dict()})
token = api.issue_agent_token_with_private_key(private_jwk, agent_id=identity.agent_id, audience="my-hub")
```

---

## SDK API Overview
Expand Down Expand Up @@ -742,6 +768,12 @@ api = HubApi(token="...", endpoint="https://modelscope.ai")
| | `get_mcp_server(server_id)` | Get server details |
| | `deploy_mcp_server(server_id)` | Deploy an MCP server |
| | `undeploy_mcp_server(server_id)` | Undeploy an MCP server |
| **Agent-IDP** | `create_agent_identity(payload)` | Register an Agent Ed25519 public key |
| | `get_agent_identity(agent_id)` / `update_agent_identity(...)` / `delete_agent_identity(agent_id)` | Manage identity metadata |
| | `reset_agent_key_pair(agent_id, payload)` / `pause_agent(agent_id, paused=...)` | Rotate a key or control token issuance |
| | `list_user_agent_identities(...)` / `list_agent_token_records(...)` | List identities and non-sensitive issuance records |
| | `issue_agent_token_with_private_key(...)` | Locally sign and exchange a short-lived JWT |
| | `get_agent_id_configuration()` / `get_agent_id_jwks()` | Anonymous OIDC discovery and JWT verification keys |
| **Cache** | `scan_cache(cache_dir)` | Inspect local cache |
| | `clear_cache(cache_dir, ...)` | Free disk space |

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies = [
"tqdm>=4.64.0",
"filelock>=3.9",
"urllib3>=1.26",
"cryptography>=41",
]

[project.optional-dependencies]
Expand Down
25 changes: 25 additions & 0 deletions src/modelscope_hub/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
from __future__ import annotations

from ._download import ProgressCallback, TqdmCallback
from .agent_idp import (
generate_agent_key_pair,
load_private_jwk,
public_jwk_from_private,
sign_agent_token_request,
write_private_jwk,
)
from .api import HubApi
from .config import HubConfig, get_default_config, set_default_config
from .constants import License, RepoType, StudioVisibility, TokenScope, Visibility
Expand All @@ -36,6 +43,12 @@
ValidationError,
)
from .types import (
AgentIdConfiguration,
AgentIdentity,
AgentIdentitySummary,
AgentJWK,
AgentToken,
AgentTokenRecord,
CachedRepoInfo,
CacheInfo,
CacheVerification,
Expand Down Expand Up @@ -65,7 +78,19 @@
# Progress callbacks
"ProgressCallback",
"TqdmCallback",
# Agent-IDP local key helpers
"generate_agent_key_pair",
"load_private_jwk",
"public_jwk_from_private",
"sign_agent_token_request",
"write_private_jwk",
# Data classes
"AgentIdentity",
"AgentIdentitySummary",
"AgentIdConfiguration",
"AgentJWK",
"AgentToken",
"AgentTokenRecord",
"CacheInfo",
"CacheVerification",
"CachedRepoInfo",
Expand Down
218 changes: 218 additions & 0 deletions src/modelscope_hub/_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,14 @@
raise_for_status,
)
from .types import (
CreateAgentIdentityPayload,
CreateSkillPayload,
CreateStudioPayload,
DeployMcpServerPayload,
PauseAgentPayload,
ResetAgentKeyPairPayload,
TokenSignPayload,
UpdateAgentIdentityPayload,
UpdateSkillSettingsPayload,
UpdateStudioSettingsPayload,
)
Expand Down Expand Up @@ -84,6 +89,10 @@
# ``GET /studios/{owner}/{repo}/logs/{type}`` caps page_size at 500.
_STUDIO_LOG_MAX_PAGE_SIZE = 500

# Agent-IDP declares these closed sets in its OpenAPI request schemas.
_AGENT_IDP_TOKEN_EXPIRIES: frozenset[int] = frozenset({300, 600, 1800, 3600})
_AGENT_IDENTITY_STATUSES: frozenset[str] = frozenset({"active", "paused"})

JSON = dict[str, Any]
QueryParams = list[tuple[str, str]]
Filters = Mapping[str, str | int | float | bool] | None
Expand Down Expand Up @@ -123,6 +132,18 @@ def _as_wire_bool(value: bool | None) -> str | None:
# deferred in that test.
# ---------------------------------------------------------------------------
OPERATION_REGISTRY: dict[str, tuple[str, TokenScope]] = {
# -- Agent-IDP ----------------------------------------------------------
"createAgentIdentity": ("create_agent_identity", TokenScope.WRITE),
"getAgentIdentity": ("get_agent_identity", TokenScope.READ),
"updateAgentIdentity": ("update_agent_identity", TokenScope.WRITE),
"deleteAgentIdentity": ("delete_agent_identity", TokenScope.WRITE),
"resetAgentKeyPair": ("reset_agent_key_pair", TokenScope.WRITE),
"pauseAgent": ("pause_agent", TokenScope.WRITE),
"listAgentTokenRecords": ("list_agent_token_records", TokenScope.READ),
"listUserAgentIdentities": ("list_user_agent_identities", TokenScope.READ),
"issueAgentToken": ("issue_agent_token", TokenScope.READ),
"getAgentIdConfiguration": ("get_agent_id_configuration", TokenScope.READ),
"getAgentIdJWKS": ("get_agent_id_jwks", TokenScope.READ),
# -- MCP ----------------------------------------------------------------
"listMcpServers": ("list_mcp_servers", TokenScope.READ),
"listOperationalMcpServers": ("list_operational_mcp_servers", TokenScope.READ),
Expand Down Expand Up @@ -1067,6 +1088,203 @@ def _flatten_mcp_list_params(body: Mapping[str, Any]) -> QueryParams:
def _is_method_or_route_unsupported(exc: APIError) -> bool:
return exc.status_code in (404, 405, 501)

# ==================================================================
# Agent-IDP
# ==================================================================
@staticmethod
def _validate_agent_idp_page(page: int, page_size: int) -> None:
"""Validate the common Agent-IDP pagination contract (1-based, max 50)."""
if isinstance(page, bool) or not isinstance(page, int) or page < 1:
raise InvalidParameter("page must be an integer >= 1.")
if isinstance(page_size, bool) or not isinstance(page_size, int) or not 1 <= page_size <= 50:
raise InvalidParameter("page_size must be an integer between 1 and 50.")

@staticmethod
def _validate_agent_public_jwk(value: Any) -> dict[str, Any]:
"""Validate the public-only Ed25519 JWK accepted by registration routes."""
if not isinstance(value, Mapping):
raise InvalidParameter("public_key must be an Ed25519 JWK object.")
key = dict(value)
if "d" in key:
raise InvalidParameter("public_key must not contain private JWK material ('d').")
required = {"kty", "crv", "x", "kid"}
missing = sorted(name for name in required if not isinstance(key.get(name), str) or not key[name])
if missing:
raise InvalidParameter(f"public_key is missing required JWK field(s): {', '.join(missing)}.")
if key["kty"] != "OKP" or key["crv"] != "Ed25519":
raise InvalidParameter("public_key must use kty='OKP' and crv='Ed25519'.")
for field in ("alg", "use"):
if field in key and key[field] is not None and not isinstance(key[field], str):
raise InvalidParameter(f"public_key.{field} must be a string.")
return {field: value for field, value in key.items() if value is not None}

@staticmethod
def _validate_agent_token_expiry(value: Any) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value not in _AGENT_IDP_TOKEN_EXPIRIES:
allowed = ", ".join(str(item) for item in sorted(_AGENT_IDP_TOKEN_EXPIRIES))
raise InvalidParameter(f"token_expire_time must be one of {allowed} seconds.")
return value

def create_agent_identity(self, payload: CreateAgentIdentityPayload | Mapping[str, Any]) -> JSON:
"""``POST /agent_ids`` — register an Agent-IDP public Ed25519 identity."""
body = {key: value for key, value in dict(payload).items() if value is not None}
allowed = {"agent_name", "description", "public_key", "key_alg_type", "token_expire_time"}
unknown = sorted(set(body) - allowed)
if unknown:
raise InvalidParameter(f"Unsupported create Agent-IDP field(s): {', '.join(unknown)}.")
if not isinstance(body.get("agent_name"), str) or not body["agent_name"].strip():
raise InvalidParameter("agent_name must be a non-empty string.")
body["public_key"] = self._validate_agent_public_jwk(body.get("public_key"))
if "key_alg_type" in body and body["key_alg_type"] != "Ed25519":
raise InvalidParameter("key_alg_type must be 'Ed25519'.")
if "token_expire_time" in body:
body["token_expire_time"] = self._validate_agent_token_expiry(body["token_expire_time"])
return self._request("POST", "/agent_ids", json_body=body, required_scope=TokenScope.WRITE)

def get_agent_identity(self, agent_id: str) -> JSON:
"""``GET /agent_ids/{agent_id}`` — fetch one Agent-IDP identity."""
if not agent_id:
raise InvalidParameter("agent_id must not be empty.")
return self._request("GET", f"/agent_ids/{agent_id}", required_scope=TokenScope.READ)

def update_agent_identity(
self,
agent_id: str,
payload: UpdateAgentIdentityPayload | Mapping[str, Any],
) -> JSON:
"""``PATCH /agent_ids/{agent_id}`` — update mutable Agent-IDP metadata."""
if not agent_id:
raise InvalidParameter("agent_id must not be empty.")
body = {key: value for key, value in dict(payload).items() if value is not None}
allowed = {"agent_name", "description", "token_expire_time"}
unknown = sorted(set(body) - allowed)
if unknown:
raise InvalidParameter(f"Unsupported Agent-IDP update field(s): {', '.join(unknown)}.")
if not body:
raise InvalidParameter("update_agent_identity requires at least one field.")
if "agent_name" in body and (not isinstance(body["agent_name"], str) or not body["agent_name"].strip()):
raise InvalidParameter("agent_name must be a non-empty string.")
if "description" in body and not isinstance(body["description"], str):
raise InvalidParameter("description must be a string.")
if "token_expire_time" in body:
body["token_expire_time"] = self._validate_agent_token_expiry(body["token_expire_time"])
return self._request("PATCH", f"/agent_ids/{agent_id}", json_body=body, required_scope=TokenScope.WRITE)

def delete_agent_identity(self, agent_id: str) -> JSON:
"""``DELETE /agent_ids/{agent_id}`` — delete an Agent-IDP identity."""
if not agent_id:
raise InvalidParameter("agent_id must not be empty.")
return self._request("DELETE", f"/agent_ids/{agent_id}", required_scope=TokenScope.WRITE)

def reset_agent_key_pair(
self,
agent_id: str,
payload: ResetAgentKeyPairPayload | Mapping[str, Any],
) -> JSON:
"""``PUT /agent_ids/{agent_id}/key_pairs`` — replace an identity public key."""
if not agent_id:
raise InvalidParameter("agent_id must not be empty.")
body = {key: value for key, value in dict(payload).items() if value is not None}
allowed = {"public_key", "key_alg_type"}
unknown = sorted(set(body) - allowed)
if unknown:
raise InvalidParameter(f"Unsupported Agent-IDP key-reset field(s): {', '.join(unknown)}.")
body["public_key"] = self._validate_agent_public_jwk(body.get("public_key"))
if "key_alg_type" in body and body["key_alg_type"] != "Ed25519":
raise InvalidParameter("key_alg_type must be 'Ed25519'.")
return self._request(
"PUT",
f"/agent_ids/{agent_id}/key_pairs",
json_body=body,
required_scope=TokenScope.WRITE,
)

def pause_agent(self, agent_id: str, payload: PauseAgentPayload | Mapping[str, Any]) -> JSON:
"""``POST /agent_ids/{agent_id}/paused`` — enable or pause token issuance."""
if not agent_id:
raise InvalidParameter("agent_id must not be empty.")
body = dict(payload)
if set(body) != {"paused"} or not isinstance(body.get("paused"), bool):
raise InvalidParameter("pause_agent requires exactly a boolean 'paused' field.")
return self._request(
"POST",
f"/agent_ids/{agent_id}/paused",
json_body=body,
required_scope=TokenScope.WRITE,
)

def list_agent_token_records(self, agent_id: str, *, page: int = 1, page_size: int = 20) -> JSON:
"""``GET /agent_ids/{agent_id}/jwt_id_tokens`` — list issued-token records."""
if not agent_id:
raise InvalidParameter("agent_id must not be empty.")
self._validate_agent_idp_page(page, page_size)
return self._request(
"GET",
f"/agent_ids/{agent_id}/jwt_id_tokens",
params=self._merge_params({"page": page, "page_size": page_size}),
required_scope=TokenScope.READ,
)

def list_user_agent_identities(
self,
username: str,
*,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> JSON:
"""``GET /users/{username}/agent_ids`` — list a user's Agent-IDP identities."""
if not username:
raise InvalidParameter("username must not be empty.")
if status is not None and status not in _AGENT_IDENTITY_STATUSES:
allowed = ", ".join(sorted(_AGENT_IDENTITY_STATUSES))
raise InvalidParameter(f"status must be one of {allowed}.")
self._validate_agent_idp_page(page, page_size)
return self._request(
"GET",
f"/users/{username}/agent_ids",
params=self._merge_params({"status": status, "page": page, "page_size": page_size}),
required_scope=TokenScope.READ,
)

def issue_agent_token(self, payload: TokenSignPayload | Mapping[str, Any]) -> JSON:
"""``POST /agent_id/token`` — exchange a locally signed request for a JWT.

The signature itself authenticates this public endpoint. Explicit
anonymous transport ensures an ambient Hub token is never attached.
"""
body = dict(payload)
required = {"agent_id", "kid", "audience", "timestamp", "signature"}
if set(body) != required:
raise InvalidParameter("issue_agent_token requires agent_id, kid, audience, timestamp and signature.")
for field in ("agent_id", "kid", "audience", "signature"):
if not isinstance(body[field], str) or not body[field]:
raise InvalidParameter(f"{field} must be a non-empty string.")
if isinstance(body["timestamp"], bool) or not isinstance(body["timestamp"], int) or body["timestamp"] <= 0:
raise InvalidParameter("timestamp must be a positive Unix timestamp in seconds.")
return self._request("POST", "/agent_id/token", json_body=body, require_token=False, anonymous=True)

def get_agent_id_configuration(self) -> JSON:
"""Fetch anonymous Agent-IDP OIDC discovery metadata."""
return self._request(
"GET",
"/agent_id/.well-known/agentid-configuration",
require_token=False,
anonymous=True,
)

def get_agent_id_jwks(self) -> JSON:
"""Fetch the anonymous Agent-IDP JWT validation key set."""
return self._request(
"GET",
"/agent_id/.well-known/agentid-jwks",
require_token=False,
anonymous=True,
)

# ==================================================================
# MCP
# ==================================================================
def list_mcp_servers(
self,
*,
Expand Down
Loading
Loading