mcp-server-sms 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Volcengine SMS API tools for MCP."""
2
+
3
+ __version__ = "0.1.0"
mcp_server_sms/auth.py ADDED
@@ -0,0 +1,46 @@
1
+ """Per-call credentials. HTTP never falls back to the server's environment."""
2
+
3
+ import base64
4
+ import binascii
5
+ import json
6
+ import os
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+
10
+
11
+ class AuthenticationError(ValueError):
12
+ pass
13
+
14
+
15
+ @dataclass(frozen=True, repr=False)
16
+ class Credentials:
17
+ access_key: str
18
+ secret_key: str
19
+ session_token: str = ""
20
+
21
+ @classmethod
22
+ def from_headers(cls, headers: Mapping[str, str] | None):
23
+ if headers is None:
24
+ values = {
25
+ "AccessKeyId": os.getenv("VOLCENGINE_ACCESS_KEY"),
26
+ "SecretAccessKey": os.getenv("VOLCENGINE_SECRET_KEY"),
27
+ "SessionToken": os.getenv("VOLCENGINE_SESSION_TOKEN", ""),
28
+ }
29
+ else:
30
+ authorization = headers.get("authorization", "")
31
+ scheme, separator, encoded = authorization.partition(" ")
32
+ if not separator or scheme.lower() != "bearer" or len(encoded) > 16384:
33
+ raise AuthenticationError("需要 Authorization: Bearer <Base64 JSON 短信凭据>")
34
+ try:
35
+ values = json.loads(base64.b64decode(encoded, validate=True))
36
+ except (ValueError, UnicodeError, binascii.Error):
37
+ raise AuthenticationError("短信调用凭据格式无效") from None
38
+ if not isinstance(values, dict) or any(
39
+ not isinstance(values.get(key), str) or not values[key]
40
+ for key in ("AccessKeyId", "SecretAccessKey")
41
+ ):
42
+ raise AuthenticationError("需要完整的短信 AccessKeyId 和 SecretAccessKey")
43
+ token = values.get("SessionToken", "")
44
+ if not isinstance(token, str):
45
+ raise AuthenticationError("SessionToken 必须是字符串")
46
+ return cls(values["AccessKeyId"], values["SecretAccessKey"], token)
@@ -0,0 +1,309 @@
1
+ """SMS HTTP boundary: official V4 signing, one request, structured outcomes."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from http.cookiejar import CookieJar, DefaultCookiePolicy
6
+ from typing import Any, Literal
7
+
8
+ import httpx
9
+ from volcengine.auth.SignerV4 import SignerV4
10
+ from volcengine.base.Request import Request
11
+ from volcengine.Credentials import Credentials as SigningCredentials
12
+
13
+ from .auth import Credentials
14
+
15
+ API_HOST = "sms.volcengineapi.com"
16
+ API_VERSION = "2026-01-01"
17
+ API_REGION = "cn-north-1"
18
+ API_SERVICE = "volcSMS"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Action:
23
+ method: Literal["GET", "POST"]
24
+ write: bool = False
25
+ result_format: Literal["json", "csv", "id"] = "json"
26
+ required_any: tuple[str, ...] = ()
27
+ required_all: tuple[str, ...] = ()
28
+ opaque_result_fields: tuple[str, ...] = ()
29
+
30
+
31
+ # Only named tools can select an action; callers cannot supply arbitrary APIs.
32
+ ACTIONS = {
33
+ "ListSubAccountForAgent": Action("POST"),
34
+ "GetSubAccountDetail": Action("GET"),
35
+ "ListAllSmsProduct": Action("GET"),
36
+ "GetAccountIdentRankForAgent": Action("GET"),
37
+ "GetSignatureIdentificationList": Action("POST"),
38
+ "GetOCRLicenseForAgent": Action("POST"),
39
+ "ThreeElementEnterpriseCheckForAgent": Action("POST"),
40
+ "ThreeElementPersonCheckForAgent": Action("POST"),
41
+ "SendSmsVerifyCodeByMobile": Action("POST", True, required_any=("messageId",)),
42
+ "CheckSmsVerifyCodeByMobile": Action("POST", True, required_any=("status",)),
43
+ "ApplySignatureIdentificationForAgent": Action("POST", True, "id"),
44
+ "ListSignatureForAgent": Action("POST"),
45
+ "ApplySmsSignatureV2": Action("POST", True, required_any=("applyId", "status")),
46
+ "ListSmsTemplateForAgent": Action("POST"),
47
+ "ListSecondTemplate": Action("GET"),
48
+ "ApplySmsTemplateV2": Action("POST", True, required_any=("templateId", "status")),
49
+ "SendSmsForAgent": Action("POST", True, required_any=("MessageId", "MessageIds")),
50
+ "ListSmsSendLogForAgent": Action("POST"),
51
+ "ListTotalSendCountStatForAgent": Action("POST"),
52
+ "GetUploadTosURL": Action(
53
+ "GET", True, required_all=("file", "url"), opaque_result_fields=("url",)
54
+ ),
55
+ "TemplateUploadDemo": Action("POST", result_format="csv"),
56
+ "SetBatchTask": Action("POST", True, required_any=("taskId",)),
57
+ "GetBatchTaskDetail": Action("GET"),
58
+ "GetBatchTaskList": Action("GET"),
59
+ "ConsentBatchTask": Action("POST", True),
60
+ "DeleteBatchTask": Action("POST", True),
61
+ }
62
+
63
+ _CREDENTIAL_FIELDS = frozenset(
64
+ {
65
+ "accesskeyid",
66
+ "accesskey",
67
+ "secretaccesskey",
68
+ "secretkey",
69
+ "sessiontoken",
70
+ "securitytoken",
71
+ "authorization",
72
+ "cookie",
73
+ "set-cookie",
74
+ }
75
+ )
76
+
77
+ # These status codes explicitly reject the request. Unstructured timeout,
78
+ # conflict and gateway-specific responses remain uncertain for mutations.
79
+ _REJECTED_HTTP_STATUSES = frozenset({400, 401, 403, 404, 405, 413, 415, 422, 429})
80
+
81
+
82
+ class _RejectCookies(DefaultCookiePolicy):
83
+ """Connection pooling must not introduce cross-caller cookie state."""
84
+
85
+ def set_ok(self, cookie, request):
86
+ return False
87
+
88
+
89
+ def safe_result(
90
+ value: Any, credentials: Credentials, *, opaque_fields: tuple[str, ...] = ()
91
+ ) -> Any:
92
+ """Redact credentials while preserving declared top-level API references."""
93
+ if isinstance(value, dict):
94
+ return {
95
+ key: item if key in opaque_fields else safe_result(item, credentials)
96
+ for key, item in value.items()
97
+ if key.lower() not in _CREDENTIAL_FIELDS
98
+ }
99
+ if isinstance(value, list):
100
+ return [safe_result(item, credentials) for item in value]
101
+ if isinstance(value, str):
102
+ for secret in (credentials.access_key, credentials.secret_key, credentials.session_token):
103
+ if secret:
104
+ value = value.replace(secret, "[REDACTED]")
105
+ return value
106
+
107
+
108
+ def failure(action, code, message, request_id=None, *, outcome_unknown=False, http_status=None):
109
+ error = {"code": code, "message": message, "outcome_unknown": outcome_unknown}
110
+ if http_status is not None:
111
+ error["http_status"] = http_status
112
+ return {
113
+ "success": False,
114
+ "action": action,
115
+ "request_id": request_id,
116
+ "result": None,
117
+ "error": error,
118
+ }
119
+
120
+
121
+ class SmsClient:
122
+ def __init__(self, *, transport: httpx.AsyncBaseTransport | None = None, timeout: float = 15):
123
+ self._http = httpx.AsyncClient(
124
+ transport=transport,
125
+ timeout=timeout,
126
+ follow_redirects=False,
127
+ cookies=CookieJar(policy=_RejectCookies()),
128
+ )
129
+
130
+ async def __aenter__(self):
131
+ await self._http.__aenter__()
132
+ return self
133
+
134
+ async def __aexit__(self, exc_type, exc_value, traceback):
135
+ await self._http.__aexit__(exc_type, exc_value, traceback)
136
+
137
+ async def call(self, action: str, params: dict, credentials: Credentials) -> dict:
138
+ spec = ACTIONS[action]
139
+ wire = {key: value for key, value in params.items() if value is not None}
140
+ request = Request()
141
+ request.schema = "https"
142
+ request.set_host(API_HOST)
143
+ request.set_path("/")
144
+ request.set_method(spec.method)
145
+ request.set_headers({"Accept": "application/json", "Content-Type": "application/json"})
146
+ query = {"Action": action, "Version": API_VERSION}
147
+ if spec.method == "GET":
148
+ query.update(
149
+ {
150
+ key: str(value).lower() if isinstance(value, bool) else str(value)
151
+ for key, value in wire.items()
152
+ }
153
+ )
154
+ else:
155
+ request.set_body(json.dumps(wire, ensure_ascii=False, separators=(",", ":")))
156
+ request.set_query(query)
157
+ SignerV4.sign(
158
+ request,
159
+ SigningCredentials(
160
+ credentials.access_key,
161
+ credentials.secret_key,
162
+ API_SERVICE,
163
+ API_REGION,
164
+ credentials.session_token,
165
+ ),
166
+ )
167
+ try:
168
+ response = await self._http.request(
169
+ spec.method,
170
+ request.build(),
171
+ headers=request.headers,
172
+ content=request.body.encode("utf-8") if request.body else None,
173
+ )
174
+ except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout):
175
+ return failure(action, "connection_failed", "未建立 API 连接,请检查网络")
176
+ except httpx.HTTPError:
177
+ return failure(
178
+ action,
179
+ "outcome_unknown" if spec.write else "transport_error",
180
+ "请求结果暂时无法确认;写操作不要自动重试",
181
+ outcome_unknown=spec.write,
182
+ )
183
+ return self.decode(action, response, credentials)
184
+
185
+ @staticmethod
186
+ def decode(action: str, response: httpx.Response, credentials: Credentials) -> dict:
187
+ spec = ACTIONS[action]
188
+ request_id = response.headers.get("x-tt-logid") or response.headers.get("x-request-id")
189
+ request_id = safe_result(request_id, credentials)
190
+ try:
191
+ payload = response.json()
192
+ except ValueError:
193
+ content_type = response.headers.get("content-type", "").split(";", 1)[0]
194
+ if (
195
+ spec.result_format == "csv"
196
+ and response.is_success
197
+ and content_type
198
+ in {
199
+ "text/csv",
200
+ "application/csv",
201
+ "application/octet-stream",
202
+ "application/vnd.ms-excel",
203
+ "text/comma-separated-values",
204
+ }
205
+ ):
206
+ try:
207
+ result = {
208
+ "content": response.content.decode("utf-8-sig"),
209
+ "contentType": "text/csv",
210
+ }
211
+ except UnicodeError:
212
+ return failure(
213
+ action,
214
+ "invalid_response",
215
+ "CSV 响应不是 UTF-8",
216
+ request_id,
217
+ http_status=response.status_code,
218
+ )
219
+ return {
220
+ "success": True,
221
+ "action": action,
222
+ "request_id": request_id,
223
+ "result": safe_result(result, credentials),
224
+ "error": None,
225
+ }
226
+ payload = None
227
+ metadata = payload.get("ResponseMetadata", {}) if isinstance(payload, dict) else {}
228
+ if isinstance(metadata, dict):
229
+ request_id = metadata.get("RequestId") or request_id
230
+ else:
231
+ metadata = {}
232
+ request_id = safe_result(request_id, credentials)
233
+ if response.status_code >= 500 and spec.write:
234
+ return failure(
235
+ action,
236
+ "outcome_unknown",
237
+ "服务端异常,不能确认写入结果",
238
+ request_id,
239
+ outcome_unknown=True,
240
+ http_status=response.status_code,
241
+ )
242
+ api_error = metadata.get("Error")
243
+ if isinstance(api_error, dict):
244
+ # API error messages can echo identity documents, codes or auth input.
245
+ # Preserve the public error code and RequestId, never echo raw input.
246
+ code = safe_result(str(api_error.get("Code") or "api_error"), credentials)
247
+ return failure(
248
+ action,
249
+ code,
250
+ "短信 API 返回错误,请结合错误码和 RequestId 排查",
251
+ request_id,
252
+ http_status=response.status_code,
253
+ )
254
+ if response.status_code in _REJECTED_HTTP_STATUSES:
255
+ return failure(
256
+ action,
257
+ f"http_{response.status_code}",
258
+ "短信 API 拒绝了请求",
259
+ request_id,
260
+ http_status=response.status_code,
261
+ )
262
+ if not response.is_success or not isinstance(payload, dict) or "Result" not in payload:
263
+ return failure(
264
+ action,
265
+ "outcome_unknown" if spec.write else "invalid_response",
266
+ "未收到可确认的短信 API 响应",
267
+ request_id,
268
+ outcome_unknown=spec.write,
269
+ http_status=response.status_code,
270
+ )
271
+ result = payload["Result"]
272
+ valid = True
273
+ if spec.result_format == "id":
274
+ valid = type(result) is int and result > 0
275
+ elif spec.required_any:
276
+ valid = isinstance(result, dict) and any(
277
+ key in result
278
+ and result[key] is not None
279
+ and result[key] != ""
280
+ and result[key] != []
281
+ for key in spec.required_any
282
+ )
283
+ if spec.required_all:
284
+ valid = (
285
+ valid
286
+ and isinstance(result, dict)
287
+ and all(
288
+ isinstance(result.get(key), str) and bool(result[key])
289
+ for key in spec.required_all
290
+ )
291
+ )
292
+ if not valid:
293
+ return failure(
294
+ action,
295
+ "outcome_unknown" if spec.write else "invalid_response",
296
+ "接口响应缺少必要结果,不能确认操作完成",
297
+ request_id,
298
+ outcome_unknown=spec.write,
299
+ http_status=response.status_code,
300
+ )
301
+ return {
302
+ "success": True,
303
+ "action": action,
304
+ "request_id": request_id,
305
+ # A signed upload URL is an operational capability, not a display
306
+ # string. Editing its query parameters would invalidate the signature.
307
+ "result": safe_result(result, credentials, opaque_fields=spec.opaque_result_fields),
308
+ "error": None,
309
+ }
@@ -0,0 +1,132 @@
1
+ """Request schemas for SMS APIs, without drafts or client interaction state."""
2
+
3
+ from typing import Annotated, Literal
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ Channel = Literal["CN_OTP", "CN_NTC", "CN_MKT"]
8
+ BatchChannel = Literal["CN_NTC", "CN_MKT"]
9
+ Mobile = Annotated[str, Field(pattern=r"^1[3-9][0-9]{9}$")]
10
+ Text = Annotated[str, Field(min_length=1)]
11
+
12
+
13
+ class ApiModel(BaseModel):
14
+ model_config = ConfigDict(extra="forbid", populate_by_name=True, hide_input_in_errors=True)
15
+
16
+ def payload(self) -> dict:
17
+ return self.model_dump(by_alias=True, exclude_none=True)
18
+
19
+
20
+ class MaterialFile(ApiModel):
21
+ file_type: int = Field(alias="fileType", ge=0, le=19)
22
+ file_content: str = Field(
23
+ alias="fileContent", repr=False, description="短信材料接口接受的文件引用;不是 MCP fileId"
24
+ )
25
+ file_suffix: str | None = Field(None, alias="fileSuffix")
26
+
27
+
28
+ class BusinessInfo(ApiModel):
29
+ business_certificate_type: Literal[1, 4, 6, 7] = Field(alias="businessCertificateType")
30
+ business_certificate_name: Text = Field(alias="businessCertificateName")
31
+ unified_social_credit_identifier: Text = Field(
32
+ alias="unifiedSocialCreditIdentifier", repr=False
33
+ )
34
+ legal_person_name: Text = Field(alias="legalPersonName", repr=False)
35
+ business_certificate: MaterialFile | None = Field(None, alias="businessCertificate")
36
+ validity_start: str = Field(alias="businessCertificateValidityPeriodStart")
37
+ validity_end: str = Field(alias="businessCertificateValidityPeriodEnd")
38
+
39
+
40
+ class PersonInfo(ApiModel):
41
+ certificate_type: Literal[0, 1, 2, 3, 4, 9] = Field(alias="certificateType")
42
+ person_name: str = Field(alias="personName", repr=False)
43
+ person_id_card: str = Field(alias="personIDCard", repr=False)
44
+ person_mobile: str | None = Field(None, alias="personMobile", repr=False)
45
+ person_certificate: list[MaterialFile] | None = Field(
46
+ None, alias="personCertificate", repr=False
47
+ )
48
+
49
+
50
+ class QualificationApplication(ApiModel):
51
+ purpose: Literal[1, 2]
52
+ material_name: str = Field(alias="materialName", min_length=1, max_length=20)
53
+ business_info: BusinessInfo = Field(alias="businessInfo")
54
+ operator_person: PersonInfo = Field(alias="operatorPerson")
55
+ responsible_person_info: PersonInfo = Field(alias="responsiblePersonInfo")
56
+ same_operator: bool = Field(alias="sameOperator")
57
+ business_check_ticket: str | None = Field(None, alias="businessCheckTicket", repr=False)
58
+ operator_check_ticket: str | None = Field(None, alias="operatorCheckTicket", repr=False)
59
+ responsible_check_ticket: str | None = Field(None, alias="responsibleCheckTicket", repr=False)
60
+ legal_person: PersonInfo | None = Field(None, alias="legalPerson")
61
+ legal_check_ticket: str | None = Field(None, alias="legalCheckTicket", repr=False)
62
+ power_of_attorney: list[MaterialFile] | None = Field(None, alias="powerOfAttorney")
63
+ other_materials: list[MaterialFile] | None = Field(None, alias="otherMaterials")
64
+ effect_signatures: list[str] | None = Field(None, alias="effectSignatures")
65
+ authorizer: str | None = None
66
+ authorizee: str | None = None
67
+
68
+
69
+ class AppIcp(ApiModel):
70
+ app_icp_filling: str = Field(alias="appIcpFilling")
71
+
72
+
73
+ class Trademark(ApiModel):
74
+ trademark_cn: str | None = Field(None, alias="trademarkCn")
75
+ trademark_en: str | None = Field(None, alias="trademarkEn")
76
+ trademark_number: str | None = Field(None, alias="trademarkNumber")
77
+
78
+
79
+ class SignatureApplication(ApiModel):
80
+ content: Text
81
+ purpose: Literal[1, 2]
82
+ qualification_id: int = Field(alias="signatureIdentificationID", gt=0)
83
+ source: int
84
+ sub_accounts: list[str] | None = Field(None, alias="subAccounts")
85
+ channel_types: list[Channel] | None = Field(None, alias="channelTypes")
86
+ description: str | None = Field(None, alias="desc")
87
+ domain: str | None = None
88
+ scene: str | None = None
89
+ project_name: str | None = Field(None, alias="projectName")
90
+ app_icp: AppIcp | None = Field(None, alias="appIcp")
91
+ trademark: Trademark | None = None
92
+
93
+
94
+ class TemplateParameter(ApiModel):
95
+ name: Text
96
+
97
+
98
+ class ShortUrlConfig(ApiModel):
99
+ is_enabled: str | None = Field(None, alias="isEnabled")
100
+ belong: str | None = None
101
+ is_need_click_details: str | None = Field(None, alias="isNeedClickDetails")
102
+ ua_check_strategy: int | None = Field(None, alias="uaCheckStrategy")
103
+
104
+
105
+ class TemplateApplication(ApiModel):
106
+ name: Text
107
+ content: Text
108
+ channel_type: Channel = Field(alias="channelType")
109
+ area: Literal["cn"] = "cn"
110
+ signatures: list[str] | None = None
111
+ sub_accounts: list[str] | None = Field(None, alias="subAccounts")
112
+ template_params: list[TemplateParameter] | None = Field(None, alias="templateParams")
113
+ description: str | None = Field(None, alias="desc")
114
+ project: str | None = None
115
+ short_url_config: ShortUrlConfig | None = Field(None, alias="shortUrlConfig")
116
+
117
+
118
+ class BatchTask(ApiModel):
119
+ sub_account: Text = Field(alias="subAccount")
120
+ name: Text
121
+ signature: Text
122
+ template_id: Text = Field(alias="templateId")
123
+ template_name: str = Field(alias="templateName")
124
+ channel_type: BatchChannel = Field(alias="channelType")
125
+ file_url: Text = Field(alias="fileUrl", description="GetUploadTosURL 返回的 file 对象 Key")
126
+ scheduled: bool
127
+ send_time: int = Field(
128
+ alias="sendTime",
129
+ ge=0,
130
+ description="短信 API 的发送时间;立即发送传 0,定时发送使用 Unix 秒",
131
+ )
132
+ extra: dict | None = None