Skip to content

Commit e4d84a3

Browse files
authored
Merge pull request #2 from zhangzqs/feature/cdn-refresh
add CDN、CI、Schema Checker
2 parents 8f64ecf + 15717d2 commit e4d84a3

File tree

24 files changed

+692
-324
lines changed

24 files changed

+692
-324
lines changed

.github/workflows/ruff.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
name: Ruff
2+
on: [push, pull_request]
3+
jobs:
4+
ruff:
5+
runs-on: ubuntu-latest
6+
steps:
7+
- uses: actions/checkout@v4
8+
- uses: astral-sh/ruff-action@v3

mcp_server/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@
88
logger = logging.getLogger(consts.LOGGER_NAME)
99
logger.info("Initializing MCP server package")
1010

11-
__all__ = ['main']
11+
__all__ = ["main"]

mcp_server/application.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,12 @@
1919
core.load()
2020
server = Server("mcp-simple-resource")
2121

22+
2223
@server.set_logging_level()
2324
async def set_logging_level(level: LoggingLevel) -> EmptyResult:
2425
logger.setLevel(level.lower())
2526
await server.request_context.session.send_log_message(
26-
level="warning",
27-
data=f"Log level set to {level}",
28-
logger="mcp_s3_server"
27+
level="warning", data=f"Log level set to {level}", logger="mcp_s3_server"
2928
)
3029
return EmptyResult()
3130

@@ -38,14 +37,9 @@ async def list_resources(**kwargs) -> list[types.Resource]:
3837
resource_list.append(result)
3938
return resource_list
4039

40+
4141
@server.read_resource()
4242
async def read_resource(uri: AnyUrl) -> str:
43-
"""
44-
Read content from an S3 resource and return structured response
45-
46-
Returns:
47-
Dict containing 'contents' list with uri, mimeType, and text for each resource
48-
"""
4943
return await resource.read_resource(uri)
5044

5145

@@ -55,7 +49,5 @@ async def handle_list_tools() -> list[Tool]:
5549

5650

5751
@server.call_tool()
58-
async def fetch_tool(
59-
name: str, arguments: dict
60-
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
61-
return await tools.fetch_tool(name, arguments)
52+
async def call_tool(name: str, arguments: dict):
53+
return await tools.call_tool(name, arguments)

mcp_server/config/config.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
# Load environment variables at package initialization
1818
load_dotenv()
1919

20+
2021
@dataclass
2122
class Config:
2223
access_key: str
@@ -28,11 +29,11 @@ class Config:
2829

2930
def load_config() -> Config:
3031
config = Config(
31-
access_key=os.getenv(_CONFIG_ENV_KEY_ACCESS_KEY, ''),
32-
secret_key=os.getenv(_CONFIG_ENV_KEY_SECRET_KEY, ''),
33-
endpoint_url=os.getenv(_CONFIG_ENV_KEY_ENDPOINT_URL, ''),
34-
region_name=os.getenv(_CONFIG_ENV_KEY_REGION_NAME, ''),
35-
buckets=_get_configured_buckets_from_env()
32+
access_key=os.getenv(_CONFIG_ENV_KEY_ACCESS_KEY, ""),
33+
secret_key=os.getenv(_CONFIG_ENV_KEY_SECRET_KEY, ""),
34+
endpoint_url=os.getenv(_CONFIG_ENV_KEY_ENDPOINT_URL, ""),
35+
region_name=os.getenv(_CONFIG_ENV_KEY_REGION_NAME, ""),
36+
buckets=_get_configured_buckets_from_env(),
3637
)
3738

3839
if not config.access_key:
@@ -61,10 +62,11 @@ def load_config() -> Config:
6162
logger.info(f"Configured buckets: {config.buckets}")
6263
return config
6364

65+
6466
def _get_configured_buckets_from_env() -> List[str]:
6567
bucket_list = os.getenv(_CONFIG_ENV_KEY_BUCKETS)
6668
if bucket_list:
67-
buckets = [b.strip() for b in bucket_list.split(',')]
69+
buckets = [b.strip() for b in bucket_list.split(",")]
6870
return buckets
6971
else:
7072
return []

mcp_server/consts/consts.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
21
LOGGER_NAME = "qiniu-s3-mcp"

mcp_server/core/__init__.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
2-
from .storage import loader as storage_loader
3-
from .media_processing import loader as media_processing_loader
41
from ..config import config
2+
from .storage import load as load_storage
3+
from .media_processing import load as load_media_processing
4+
from .cdn import load as load_cdn
55

66

77
def load():
88
# 加载配置
9-
_conf = config.load_config()
10-
11-
# 存储业务
12-
storage_loader.load(_conf)
9+
cfg = config.load_config()
1310

14-
# dora
15-
media_processing_loader.load(_conf)
11+
load_storage(cfg) # 存储业务
12+
load_cdn(cfg) # CDN
13+
load_media_processing(cfg) # dora

mcp_server/core/cdn/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from .tools import register_tools
2+
from ...config import config
3+
from .cdn import CDNService
4+
5+
6+
def load(cfg: config.Config):
7+
cdn = CDNService(cfg)
8+
register_tools(cdn)
9+
10+
11+
__all__ = [
12+
"load",
13+
]

mcp_server/core/cdn/cdn.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import logging
2+
3+
from qiniu import CdnManager, Auth
4+
from qiniu.http import ResponseInfo
5+
from typing import List, Optional, Dict
6+
from pydantic import BaseModel
7+
from dataclasses import dataclass
8+
9+
from ...consts import consts
10+
from ...config import config
11+
12+
logger = logging.getLogger(consts.LOGGER_NAME)
13+
14+
15+
@dataclass
16+
class PrefetchUrlsResult(BaseModel):
17+
code: Optional[int] = None
18+
error: Optional[str] = None
19+
requestId: Optional[str] = None
20+
invalidUrls: Optional[List[str]] = None
21+
quotaDay: Optional[int] = None
22+
surplusDay: Optional[int] = None
23+
24+
25+
@dataclass
26+
class RefreshResult(BaseModel):
27+
code: Optional[int] = None
28+
error: Optional[str] = None
29+
requestId: Optional[str] = None
30+
taskIds: Optional[Dict[str, str]] = None
31+
invalidUrls: Optional[List[str]] = None
32+
invalidDirs: Optional[List[str]] = None
33+
urlQuotaDay: Optional[int] = None
34+
urlSurplusDay: Optional[int] = None
35+
dirQuotaDay: Optional[int] = None
36+
dirSurplusDay: Optional[int] = None
37+
38+
39+
def _raise_if_resp_error(resp: ResponseInfo):
40+
if resp.ok():
41+
return
42+
raise RuntimeError(f"qiniu response error: {str(resp)}")
43+
44+
45+
class CDNService:
46+
def __init__(self, cfg: config.Config):
47+
auth = Auth(access_key=cfg.access_key, secret_key=cfg.secret_key)
48+
self._cdn_manager = CdnManager(auth)
49+
50+
def prefetch_urls(self, urls: List[str] = []) -> PrefetchUrlsResult:
51+
if not urls:
52+
raise ValueError("urls is empty")
53+
info, resp = self._cdn_manager.prefetch_urls(urls)
54+
_raise_if_resp_error(resp)
55+
return PrefetchUrlsResult.model_validate(info)
56+
57+
def refresh(self, urls: List[str] = [], dirs: List[str] = []) -> RefreshResult:
58+
if not urls and not dirs:
59+
raise ValueError("urls and dirs cannot be empty")
60+
info, resp = self._cdn_manager.refresh_urls_and_dirs(urls, dirs)
61+
_raise_if_resp_error(resp)
62+
return RefreshResult.model_validate(info)
63+
64+
65+
__all__ = [
66+
"PrefetchUrlsResult",
67+
"RefreshResult",
68+
"CDNService",
69+
]

mcp_server/core/cdn/tools.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
from .cdn import CDNService
2+
from ...consts import consts
3+
from ...tools import tools
4+
import logging
5+
from mcp import types
6+
from typing import Optional, List
7+
8+
logger = logging.getLogger(consts.LOGGER_NAME)
9+
10+
11+
def _build_base_list(
12+
code: Optional[int],
13+
error: Optional[str],
14+
request_id: Optional[str],
15+
) -> List[str]:
16+
rets = []
17+
if code:
18+
rets.append(f"Status Code: {code}")
19+
if error:
20+
rets.append(f"Message: {error}")
21+
if request_id:
22+
rets.append(f"RequestID: {request_id}")
23+
return rets
24+
25+
26+
class _ToolImpl:
27+
def __init__(self, cdn: CDNService):
28+
self._cdn = cdn
29+
30+
@tools.tool_meta(
31+
types.Tool(
32+
name="CDNPrefetchUrls",
33+
description="Newly added resources are proactively retrieved by the CDN and stored on its cache nodes in advance. Users simply submit the resource URLs, and the CDN automatically triggers the prefetch process.",
34+
inputSchema={
35+
"type": "object",
36+
"additionalProperties": False,
37+
"properties": {
38+
"urls": {
39+
"type": "array",
40+
"description": "List of individual URLs to prefetch (max 60 items). Must be full URLs with protocol, e.g. 'http://example.com/file.zip'",
41+
"items": {
42+
"type": "string",
43+
"format": "uri",
44+
"pattern": "^https?://",
45+
"examples": [
46+
"https://cdn.example.com/images/photo.jpg",
47+
"http://static.example.com/downloads/app.exe",
48+
],
49+
},
50+
"maxItems": 60,
51+
"minItems": 1,
52+
}
53+
},
54+
"required": ["urls"],
55+
},
56+
)
57+
)
58+
def prefetch_urls(self, **kwargs) -> list[types.TextContent]:
59+
ret = self._cdn.prefetch_urls(**kwargs)
60+
61+
rets = _build_base_list(ret.code, ret.error, ret.requestId)
62+
if ret.invalidUrls:
63+
rets.append(f"Invalid URLs: {ret.invalidUrls}")
64+
if ret.code // 100 == 2:
65+
if ret.quotaDay is not None:
66+
rets.append(f"Today's prefetch quota: {ret.quotaDay}")
67+
if ret.surplusDay is not None:
68+
rets.append(f"Today's remaining quota: {ret.surplusDay}")
69+
70+
return [
71+
types.TextContent(
72+
type="text",
73+
text="\n".join(rets),
74+
)
75+
]
76+
77+
@tools.tool_meta(
78+
types.Tool(
79+
name="CDNRefresh",
80+
description="This function marks resources cached on CDN nodes as expired. When users access these resources again, the CDN nodes will fetch the latest version from the origin server and store them anew.",
81+
inputSchema={
82+
"type": "object",
83+
"additionalProperties": False, # 不允许出现未定义的属性
84+
"properties": {
85+
"urls": {
86+
"type": "array",
87+
"items": {
88+
"type": "string",
89+
"format": "uri",
90+
"pattern": "^https?://", # 匹配http://或https://开头的URL
91+
"examples": ["http://bar.foo.com/index.html"],
92+
},
93+
"maxItems": 60,
94+
"description": "List of exact URLs to refresh (max 60 items). Must be full URLs with protocol, e.g. 'http://example.com/path/page.html'",
95+
},
96+
"dirs": {
97+
"type": "array",
98+
"items": {
99+
"type": "string",
100+
"pattern": "^https?://.*/(\\*|$)", # 匹配以http://或https://开头的URL,并以/或者以/*结尾的字符串
101+
"examples": [
102+
"http://bar.foo.com/dir/",
103+
"http://bar.foo.com/images/*",
104+
],
105+
},
106+
"maxItems": 10,
107+
"description": "List of directory patterns to refresh (max 10 items). Must end with '/' or '/*' to indicate directory scope",
108+
},
109+
},
110+
"anyOf": [ # 至少有一个是非空数组
111+
{
112+
"required": ["urls"],
113+
"properties": {"urls": {"not": {"maxItems": 0}}},
114+
},
115+
{
116+
"required": ["dirs"],
117+
"properties": {"dirs": {"not": {"maxItems": 0}}},
118+
},
119+
],
120+
},
121+
)
122+
)
123+
def refresh(self, **kwargs) -> list[types.TextContent]:
124+
ret = self._cdn.refresh(**kwargs)
125+
rets = _build_base_list(ret.code, ret.error, ret.requestId)
126+
if ret.taskIds is not None:
127+
# 这个可能暂时用不到
128+
pass
129+
if ret.invalidUrls:
130+
rets.append(f"Invalid URLs list: {ret.invalidUrls}")
131+
if ret.invalidDirs:
132+
rets.append(f"Invalid dirs: {ret.invalidDirs}")
133+
134+
if ret.code // 100 == 2:
135+
if ret.urlQuotaDay is not None:
136+
rets.append(f"Today's URL refresh quota: {ret.urlQuotaDay}")
137+
if ret.urlSurplusDay is not None:
138+
rets.append(f"Today's remaining URL refresh quota: {ret.urlSurplusDay}")
139+
if ret.dirQuotaDay is not None:
140+
rets.append(f"Today's directory refresh quota: {ret.dirQuotaDay}")
141+
if ret.dirSurplusDay is not None:
142+
rets.append(
143+
f"Today's remaining directory refresh quota: {ret.dirSurplusDay}"
144+
)
145+
return [
146+
types.TextContent(
147+
type="text",
148+
text="\n".join(rets),
149+
)
150+
]
151+
152+
153+
def register_tools(cdn: CDNService):
154+
tool_impl = _ToolImpl(cdn)
155+
tools.auto_register_tools(
156+
[
157+
tool_impl.refresh,
158+
tool_impl.prefetch_urls,
159+
]
160+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from . import client
2+
from .tools import register_tools
3+
from ...config import config
4+
5+
6+
def load(cfg: config.Config):
7+
cli = client.Client(cfg)
8+
register_tools(cli)
9+
10+
11+
__all__ = [
12+
"load",
13+
]

0 commit comments

Comments
 (0)