apple-mcp-common 1.0.0__tar.gz
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.
- apple_mcp_common-1.0.0/LICENSE +21 -0
- apple_mcp_common-1.0.0/PKG-INFO +42 -0
- apple_mcp_common-1.0.0/README.md +13 -0
- apple_mcp_common-1.0.0/pyproject.toml +50 -0
- apple_mcp_common-1.0.0/setup.cfg +4 -0
- apple_mcp_common-1.0.0/src/apple_mcp_common/__init__.py +3 -0
- apple_mcp_common-1.0.0/src/apple_mcp_common/discovery.py +598 -0
- apple_mcp_common-1.0.0/src/apple_mcp_common.egg-info/PKG-INFO +42 -0
- apple_mcp_common-1.0.0/src/apple_mcp_common.egg-info/SOURCES.txt +11 -0
- apple_mcp_common-1.0.0/src/apple_mcp_common.egg-info/dependency_links.txt +1 -0
- apple_mcp_common-1.0.0/src/apple_mcp_common.egg-info/requires.txt +6 -0
- apple_mcp_common-1.0.0/src/apple_mcp_common.egg-info/top_level.txt +1 -0
- apple_mcp_common-1.0.0/tests/test_discovery.py +108 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Apple MCP Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apple-mcp-common
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Shared discovery and generation helpers for the Apple MCP suite
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/JonathanRReed/Apple-MCPs
|
|
7
|
+
Project-URL: Repository, https://github.com/JonathanRReed/Apple-MCPs
|
|
8
|
+
Project-URL: Changelog, https://github.com/JonathanRReed/Apple-MCPs/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/JonathanRReed/Apple-MCPs/issues
|
|
10
|
+
Keywords: mcp,model-context-protocol,macos,apple,ai-agent,automation
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: MacOS X
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: mcp<3,>=2.0.0
|
|
24
|
+
Requires-Dist: pydantic>=2.12.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest<10,>=8; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff<1,>=0.12; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# AppleMCPCommon
|
|
31
|
+
|
|
32
|
+
Shared Python helpers for the Apple MCP suite.
|
|
33
|
+
|
|
34
|
+
This internal package provides:
|
|
35
|
+
|
|
36
|
+
- search-first tool discovery
|
|
37
|
+
- catalog export helpers
|
|
38
|
+
- generated wrapper helpers
|
|
39
|
+
- shared parsing helpers for deferred tool-call responses
|
|
40
|
+
- generated artifacts under `generated/tool_catalogs` and `generated/tool_wrappers/python`
|
|
41
|
+
|
|
42
|
+
It is not a user-facing MCP server.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# AppleMCPCommon
|
|
2
|
+
|
|
3
|
+
Shared Python helpers for the Apple MCP suite.
|
|
4
|
+
|
|
5
|
+
This internal package provides:
|
|
6
|
+
|
|
7
|
+
- search-first tool discovery
|
|
8
|
+
- catalog export helpers
|
|
9
|
+
- generated wrapper helpers
|
|
10
|
+
- shared parsing helpers for deferred tool-call responses
|
|
11
|
+
- generated artifacts under `generated/tool_catalogs` and `generated/tool_wrappers/python`
|
|
12
|
+
|
|
13
|
+
It is not a user-facing MCP server.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "apple-mcp-common"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Shared discovery and generation helpers for the Apple MCP suite"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
keywords = ["mcp", "model-context-protocol", "macos", "apple", "ai-agent", "automation"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Environment :: MacOS X",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: MacOS",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"mcp>=2.0.0,<3",
|
|
27
|
+
"pydantic>=2.12.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/JonathanRReed/Apple-MCPs"
|
|
32
|
+
Repository = "https://github.com/JonathanRReed/Apple-MCPs"
|
|
33
|
+
Changelog = "https://github.com/JonathanRReed/Apple-MCPs/blob/main/CHANGELOG.md"
|
|
34
|
+
Issues = "https://github.com/JonathanRReed/Apple-MCPs/issues"
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest>=8,<10",
|
|
39
|
+
"ruff>=0.12,<1",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools]
|
|
43
|
+
package-dir = {"" = "src"}
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.packages.find]
|
|
46
|
+
where = ["src"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
pythonpath = ["src"]
|
|
50
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import keyword
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from mcp import types
|
|
12
|
+
from mcp.server.mcpserver import MCPServer
|
|
13
|
+
from mcp.types import ToolAnnotations
|
|
14
|
+
|
|
15
|
+
STOPWORDS = {
|
|
16
|
+
"a",
|
|
17
|
+
"an",
|
|
18
|
+
"and",
|
|
19
|
+
"app",
|
|
20
|
+
"apple",
|
|
21
|
+
"by",
|
|
22
|
+
"for",
|
|
23
|
+
"from",
|
|
24
|
+
"get",
|
|
25
|
+
"in",
|
|
26
|
+
"inside",
|
|
27
|
+
"into",
|
|
28
|
+
"list",
|
|
29
|
+
"local",
|
|
30
|
+
"macos",
|
|
31
|
+
"of",
|
|
32
|
+
"on",
|
|
33
|
+
"or",
|
|
34
|
+
"read",
|
|
35
|
+
"return",
|
|
36
|
+
"the",
|
|
37
|
+
"through",
|
|
38
|
+
"to",
|
|
39
|
+
"use",
|
|
40
|
+
"with",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
DOMAIN_ALIASES: dict[str, tuple[str, ...]] = {
|
|
44
|
+
"calendar": ("calendar", "event", "events", "meeting", "meetings", "schedule"),
|
|
45
|
+
"contacts": ("contact", "contacts", "person", "people", "recipient", "address-book"),
|
|
46
|
+
"files": ("file", "files", "folder", "folders", "document", "documents", "finder", "icloud", "downloads"),
|
|
47
|
+
"mail": ("mail", "email", "emails", "inbox", "thread"),
|
|
48
|
+
"maps": ("maps", "place", "places", "route", "directions", "restaurant", "restaurants"),
|
|
49
|
+
"messages": ("messages", "imessage", "sms", "text", "chat", "conversation"),
|
|
50
|
+
"notes": ("note", "notes", "memo"),
|
|
51
|
+
"reminders": ("reminder", "reminders", "task", "tasks", "todo"),
|
|
52
|
+
"shortcuts": ("shortcut", "shortcuts", "automation", "workflow"),
|
|
53
|
+
"system": ("system", "mac", "desktop", "clipboard", "battery", "focus", "settings"),
|
|
54
|
+
"apple": ("apple", "assistant", "unified", "cross-app"),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class ToolSearchMetadata:
|
|
59
|
+
aliases: tuple[str, ...] = ()
|
|
60
|
+
tags: tuple[str, ...] = ()
|
|
61
|
+
usage_intent: str | None = None
|
|
62
|
+
example_calls: tuple[dict[str, Any], ...] = ()
|
|
63
|
+
always_visible: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class SearchFirstDiscovery:
|
|
68
|
+
"""Registers catalog-search helper tools and powers generated tool artifacts.
|
|
69
|
+
|
|
70
|
+
Since MCP spec 2026-07-28, every tool is exposed through ``tools/list``
|
|
71
|
+
(modern clients defer-load large tool surfaces themselves). ``search_tools``
|
|
72
|
+
and ``get_tool_info`` remain as ordinary tools so context-constrained
|
|
73
|
+
clients can still discover the catalog incrementally.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
WRAPPER_ROOT_PACKAGE = "apple_mcp_wrappers"
|
|
77
|
+
mcp: MCPServer
|
|
78
|
+
server_name: str
|
|
79
|
+
domain: str
|
|
80
|
+
list_all_tools: Callable[[], Awaitable[list[types.Tool]]]
|
|
81
|
+
metadata: Mapping[str, ToolSearchMetadata] = field(default_factory=dict)
|
|
82
|
+
wrapper_namespace: str | None = None
|
|
83
|
+
discovery_tool_names: tuple[str, str] = ("search_tools", "get_tool_info")
|
|
84
|
+
|
|
85
|
+
def register(self) -> SearchFirstDiscovery:
|
|
86
|
+
async def search_tools(
|
|
87
|
+
query: str,
|
|
88
|
+
limit: int = 10,
|
|
89
|
+
domain_tags: list[str] | None = None,
|
|
90
|
+
mode: str = "compact",
|
|
91
|
+
) -> dict[str, Any]:
|
|
92
|
+
catalog = await self.catalog_entries()
|
|
93
|
+
results = self.search_catalog(query=query, catalog=catalog, limit=limit, domain_tags=domain_tags, mode=mode)
|
|
94
|
+
return {
|
|
95
|
+
"ok": True,
|
|
96
|
+
"server": self.server_name,
|
|
97
|
+
"domain": self.domain,
|
|
98
|
+
"query": query,
|
|
99
|
+
"count": len(results),
|
|
100
|
+
"results": results,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async def get_tool_info(
|
|
104
|
+
name: str,
|
|
105
|
+
include_schema: bool = True,
|
|
106
|
+
include_examples: bool = True,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
catalog = await self.catalog_entries()
|
|
109
|
+
tools_by_name = await self.tools_by_name()
|
|
110
|
+
tool = tools_by_name.get(name)
|
|
111
|
+
if tool is None:
|
|
112
|
+
suggestions = self.search_catalog(query=name, catalog=catalog, limit=5, domain_tags=None, mode="compact")
|
|
113
|
+
return {
|
|
114
|
+
"ok": False,
|
|
115
|
+
"error": {
|
|
116
|
+
"code": "UNKNOWN_TOOL",
|
|
117
|
+
"message": f"Unknown tool: {name}",
|
|
118
|
+
"suggestions": [item["name"] for item in suggestions],
|
|
119
|
+
"suggestion": "Use search_tools to find the right tool name.",
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
entry = next(item for item in catalog if item["name"] == name)
|
|
123
|
+
payload = dict(entry)
|
|
124
|
+
if include_schema:
|
|
125
|
+
payload["input_schema"] = tool.input_schema
|
|
126
|
+
payload["output_schema"] = tool.output_schema
|
|
127
|
+
else:
|
|
128
|
+
payload.pop("schema_ref", None)
|
|
129
|
+
if not include_examples:
|
|
130
|
+
payload.pop("example_calls", None)
|
|
131
|
+
payload["ok"] = True
|
|
132
|
+
return payload
|
|
133
|
+
|
|
134
|
+
self.mcp.add_tool(
|
|
135
|
+
search_tools,
|
|
136
|
+
name=self.discovery_tool_names[0],
|
|
137
|
+
title="Search Tools",
|
|
138
|
+
description="Search this server's tool catalog by keyword, alias, or domain tag.",
|
|
139
|
+
annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),
|
|
140
|
+
structured_output=True,
|
|
141
|
+
)
|
|
142
|
+
self.mcp.add_tool(
|
|
143
|
+
get_tool_info,
|
|
144
|
+
name=self.discovery_tool_names[1],
|
|
145
|
+
title="Get Tool Info",
|
|
146
|
+
description="Load the full schema, examples, and metadata for one tool from this server.",
|
|
147
|
+
annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True),
|
|
148
|
+
structured_output=True,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
async def tools_by_name(self) -> dict[str, types.Tool]:
|
|
154
|
+
tools = await self.list_all_tools()
|
|
155
|
+
tools_by_name: dict[str, types.Tool] = {}
|
|
156
|
+
for tool in tools:
|
|
157
|
+
tools_by_name.setdefault(tool.name, tool)
|
|
158
|
+
return tools_by_name
|
|
159
|
+
|
|
160
|
+
async def catalog_entries(self) -> list[dict[str, Any]]:
|
|
161
|
+
tools = await self.list_all_tools()
|
|
162
|
+
catalog: list[dict[str, Any]] = []
|
|
163
|
+
for tool in tools:
|
|
164
|
+
metadata = self.metadata.get(tool.name, ToolSearchMetadata())
|
|
165
|
+
catalog.append(self._catalog_entry(tool, metadata))
|
|
166
|
+
return catalog
|
|
167
|
+
|
|
168
|
+
async def export_catalog(self) -> dict[str, Any]:
|
|
169
|
+
tools = await self.catalog_entries()
|
|
170
|
+
return {
|
|
171
|
+
"server": self.server_name,
|
|
172
|
+
"domain": self.domain,
|
|
173
|
+
"tool_count": len(tools),
|
|
174
|
+
"tools": tools,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def search_catalog(
|
|
178
|
+
self,
|
|
179
|
+
*,
|
|
180
|
+
query: str,
|
|
181
|
+
catalog: Sequence[dict[str, Any]],
|
|
182
|
+
limit: int,
|
|
183
|
+
domain_tags: Sequence[str] | None,
|
|
184
|
+
mode: str,
|
|
185
|
+
) -> list[dict[str, Any]]:
|
|
186
|
+
normalized_query = query.strip().lower()
|
|
187
|
+
query_tokens = self._tokenize(normalized_query)
|
|
188
|
+
domain_filters = {item.lower() for item in domain_tags or []}
|
|
189
|
+
scored: list[tuple[float, dict[str, Any]]] = []
|
|
190
|
+
for entry in catalog:
|
|
191
|
+
if entry["name"] in self.discovery_tool_names:
|
|
192
|
+
continue
|
|
193
|
+
if domain_filters and not domain_filters.intersection({entry["domain"], *entry["tags"]}):
|
|
194
|
+
continue
|
|
195
|
+
score = self._score_entry(entry, normalized_query, query_tokens)
|
|
196
|
+
if normalized_query and score <= 0:
|
|
197
|
+
continue
|
|
198
|
+
scored.append((score, entry))
|
|
199
|
+
scored.sort(key=lambda item: (-item[0], item[1]["name"]))
|
|
200
|
+
results: list[dict[str, Any]] = []
|
|
201
|
+
for score, entry in scored[: max(1, limit)]:
|
|
202
|
+
shaped = {
|
|
203
|
+
"name": entry["name"],
|
|
204
|
+
"title": entry["title"],
|
|
205
|
+
"description": entry["description"],
|
|
206
|
+
"domain": entry["domain"],
|
|
207
|
+
"tags": entry["tags"],
|
|
208
|
+
"risk_level": entry["risk_level"],
|
|
209
|
+
"argument_summary": entry["argument_summary"],
|
|
210
|
+
"score": round(score, 2),
|
|
211
|
+
}
|
|
212
|
+
if mode in {"detail", "full"}:
|
|
213
|
+
shaped["usage_intent"] = entry["usage_intent"]
|
|
214
|
+
shaped["aliases"] = entry["aliases"]
|
|
215
|
+
shaped["schema_ref"] = entry["schema_ref"]
|
|
216
|
+
if mode == "full":
|
|
217
|
+
shaped["example_calls"] = entry["example_calls"]
|
|
218
|
+
shaped["read_only"] = entry["read_only"]
|
|
219
|
+
results.append(shaped)
|
|
220
|
+
return results
|
|
221
|
+
|
|
222
|
+
def _catalog_entry(self, tool: types.Tool, metadata: ToolSearchMetadata) -> dict[str, Any]:
|
|
223
|
+
tags = self._tags_for_tool(tool, metadata)
|
|
224
|
+
example_calls = list(metadata.example_calls) or [self._example_call(tool)]
|
|
225
|
+
return {
|
|
226
|
+
"name": tool.name,
|
|
227
|
+
"title": tool.title or self._title_from_name(tool.name),
|
|
228
|
+
"description": tool.description or "",
|
|
229
|
+
"domain": self.domain,
|
|
230
|
+
"usage_intent": metadata.usage_intent or self._usage_intent(tool),
|
|
231
|
+
"tags": tags,
|
|
232
|
+
"aliases": list(dict.fromkeys([*metadata.aliases, *self._aliases_for_tool(tool)])),
|
|
233
|
+
"risk_level": self._risk_level(tool),
|
|
234
|
+
"read_only": bool(getattr(tool.annotations, "read_only_hint", False)),
|
|
235
|
+
"argument_summary": self._argument_summary(tool.input_schema),
|
|
236
|
+
"example_calls": example_calls,
|
|
237
|
+
"schema_ref": tool.name,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
def _score_entry(self, entry: Mapping[str, Any], normalized_query: str, query_tokens: Sequence[str]) -> float:
|
|
241
|
+
if not normalized_query:
|
|
242
|
+
return 1.0 if entry["risk_level"] == "low" else 0.5
|
|
243
|
+
exact_tags = set(entry.get("tags", []))
|
|
244
|
+
exact_aliases = set(entry.get("aliases", []))
|
|
245
|
+
name_tokens = set(self._tokenize(entry["name"].replace("_", " ")))
|
|
246
|
+
haystacks = [
|
|
247
|
+
entry["name"].lower(),
|
|
248
|
+
(entry.get("title") or "").lower(),
|
|
249
|
+
(entry.get("description") or "").lower(),
|
|
250
|
+
" ".join(entry.get("aliases", [])).lower(),
|
|
251
|
+
" ".join(entry.get("tags", [])).lower(),
|
|
252
|
+
(entry.get("usage_intent") or "").lower(),
|
|
253
|
+
]
|
|
254
|
+
score = 0.0
|
|
255
|
+
if normalized_query == entry["name"].lower():
|
|
256
|
+
score += 100
|
|
257
|
+
if normalized_query in set(entry.get("aliases", [])):
|
|
258
|
+
score += 80
|
|
259
|
+
for haystack in haystacks:
|
|
260
|
+
if normalized_query and normalized_query in haystack:
|
|
261
|
+
score += 20
|
|
262
|
+
for token in query_tokens:
|
|
263
|
+
if token in exact_tags:
|
|
264
|
+
score += 12
|
|
265
|
+
if token in exact_aliases:
|
|
266
|
+
score += 10
|
|
267
|
+
if token in name_tokens:
|
|
268
|
+
score += 8
|
|
269
|
+
for haystack in haystacks:
|
|
270
|
+
if token == entry["domain"]:
|
|
271
|
+
score += 8
|
|
272
|
+
if token and re.search(rf"(?<![a-z0-9]){re.escape(token)}(?![a-z0-9])", haystack):
|
|
273
|
+
score += 6
|
|
274
|
+
elif token and token in haystack:
|
|
275
|
+
score += 2
|
|
276
|
+
score += self._tool_kind_bias(entry["name"], query_tokens)
|
|
277
|
+
if entry["read_only"]:
|
|
278
|
+
score += 0.25
|
|
279
|
+
return score
|
|
280
|
+
|
|
281
|
+
def _tool_kind_bias(self, tool_name: str, query_tokens: Sequence[str]) -> float:
|
|
282
|
+
tokens = tool_name.split("_")
|
|
283
|
+
query_token_set = set(query_tokens)
|
|
284
|
+
if {"suggest", "suggestion", "autocomplete", "completion", "complete"}.intersection(query_token_set):
|
|
285
|
+
suggestion_penalty = 0.0
|
|
286
|
+
else:
|
|
287
|
+
suggestion_penalty = -8.0 if "suggest" in tokens else 0.0
|
|
288
|
+
if {"preview", "draft", "plan"}.intersection(query_token_set):
|
|
289
|
+
preview_penalty = 0.0
|
|
290
|
+
else:
|
|
291
|
+
preview_penalty = -10.0 if "preview" in tokens else 0.0
|
|
292
|
+
direct_action_bonus = 0.0
|
|
293
|
+
if {"list", "get", "find", "search", "read", "create", "send", "run", "archive", "delete", "update", "write", "add"}.intersection(tokens):
|
|
294
|
+
direct_action_bonus = 4.0
|
|
295
|
+
return suggestion_penalty + preview_penalty + direct_action_bonus
|
|
296
|
+
|
|
297
|
+
def _usage_intent(self, tool: types.Tool) -> str:
|
|
298
|
+
description = (tool.description or "").strip()
|
|
299
|
+
if description:
|
|
300
|
+
return description
|
|
301
|
+
return f"Use {tool.name} when the request matches the {self.domain} domain."
|
|
302
|
+
|
|
303
|
+
def _risk_level(self, tool: types.Tool) -> str:
|
|
304
|
+
annotations = tool.annotations
|
|
305
|
+
if annotations and getattr(annotations, "destructive_hint", False):
|
|
306
|
+
return "high"
|
|
307
|
+
if annotations and getattr(annotations, "read_only_hint", False):
|
|
308
|
+
return "low"
|
|
309
|
+
return "medium"
|
|
310
|
+
|
|
311
|
+
def _argument_summary(self, schema: Mapping[str, Any]) -> list[str]:
|
|
312
|
+
properties = schema.get("properties", {}) if isinstance(schema, Mapping) else {}
|
|
313
|
+
required = set(schema.get("required", [])) if isinstance(schema, Mapping) else set()
|
|
314
|
+
summaries: list[str] = []
|
|
315
|
+
for name, definition in properties.items():
|
|
316
|
+
type_name = self._schema_type_name(definition)
|
|
317
|
+
suffix = "required" if name in required else "optional"
|
|
318
|
+
summaries.append(f"{name}: {type_name} ({suffix})")
|
|
319
|
+
return summaries[:8]
|
|
320
|
+
|
|
321
|
+
def _example_call(self, tool: types.Tool) -> dict[str, Any]:
|
|
322
|
+
schema = tool.input_schema or {}
|
|
323
|
+
properties = schema.get("properties", {}) if isinstance(schema, Mapping) else {}
|
|
324
|
+
required = set(schema.get("required", [])) if isinstance(schema, Mapping) else set()
|
|
325
|
+
arguments: dict[str, Any] = {}
|
|
326
|
+
for name, definition in properties.items():
|
|
327
|
+
if name in required or len(arguments) < 2:
|
|
328
|
+
arguments[name] = self._example_value(name, definition)
|
|
329
|
+
return {"tool": tool.name, "arguments": arguments}
|
|
330
|
+
|
|
331
|
+
def _example_value(self, name: str, definition: Mapping[str, Any]) -> Any:
|
|
332
|
+
if definition.get("enum"):
|
|
333
|
+
return definition["enum"][0]
|
|
334
|
+
schema_type = self._schema_type_name(definition)
|
|
335
|
+
if schema_type == "boolean":
|
|
336
|
+
return False
|
|
337
|
+
if schema_type == "integer":
|
|
338
|
+
return 1
|
|
339
|
+
if schema_type == "number":
|
|
340
|
+
return 1.0
|
|
341
|
+
if schema_type == "array":
|
|
342
|
+
return []
|
|
343
|
+
if schema_type == "object":
|
|
344
|
+
return {}
|
|
345
|
+
lowered = name.lower()
|
|
346
|
+
if "path" in lowered:
|
|
347
|
+
return "/path/to/item"
|
|
348
|
+
if "email" in lowered:
|
|
349
|
+
return "person@example.com"
|
|
350
|
+
if "phone" in lowered:
|
|
351
|
+
return "+15551234567"
|
|
352
|
+
if "recipient" in lowered:
|
|
353
|
+
return "Example Person"
|
|
354
|
+
if "date" in lowered or "time" in lowered or lowered.endswith("_iso"):
|
|
355
|
+
return "2026-04-07T18:00:00"
|
|
356
|
+
if "query" in lowered:
|
|
357
|
+
return f"find {self.domain}"
|
|
358
|
+
return f"example_{name}"
|
|
359
|
+
|
|
360
|
+
def _aliases_for_tool(self, tool: types.Tool) -> list[str]:
|
|
361
|
+
aliases = list(DOMAIN_ALIASES.get(self.domain, ()))
|
|
362
|
+
base_name = tool.name
|
|
363
|
+
prefix = f"{self.domain}_"
|
|
364
|
+
if base_name.startswith(prefix):
|
|
365
|
+
aliases.append(base_name[len(prefix) :].replace("_", " "))
|
|
366
|
+
aliases.append(base_name.replace("_", " "))
|
|
367
|
+
return list(dict.fromkeys(alias.lower() for alias in aliases if alias))
|
|
368
|
+
|
|
369
|
+
def _tags_for_tool(self, tool: types.Tool, metadata: ToolSearchMetadata) -> list[str]:
|
|
370
|
+
tags = [self.domain, *DOMAIN_ALIASES.get(self.domain, ())]
|
|
371
|
+
tags.extend(metadata.tags)
|
|
372
|
+
tags.extend(self._tokenize(tool.name.replace("_", " ")))
|
|
373
|
+
tags.extend(self._tokenize((tool.title or "") + " " + (tool.description or "")))
|
|
374
|
+
filtered = [token for token in tags if token and token not in STOPWORDS and len(token) > 2]
|
|
375
|
+
return list(dict.fromkeys(filtered))[:20]
|
|
376
|
+
|
|
377
|
+
def _tokenize(self, value: str) -> list[str]:
|
|
378
|
+
tokens = re.findall(r"[a-z0-9]+", value.lower())
|
|
379
|
+
expanded: list[str] = []
|
|
380
|
+
for token in tokens:
|
|
381
|
+
expanded.append(token)
|
|
382
|
+
if token.endswith("ies") and len(token) > 3:
|
|
383
|
+
expanded.append(f"{token[:-3]}y")
|
|
384
|
+
elif token.endswith("es") and len(token) > 3:
|
|
385
|
+
expanded.append(token[:-2])
|
|
386
|
+
elif token.endswith("s") and len(token) > 3:
|
|
387
|
+
expanded.append(token[:-1])
|
|
388
|
+
return list(dict.fromkeys(expanded))
|
|
389
|
+
|
|
390
|
+
def _schema_type_name(self, definition: Mapping[str, Any]) -> str:
|
|
391
|
+
schema_type = definition.get("type")
|
|
392
|
+
if isinstance(schema_type, list):
|
|
393
|
+
non_null = [item for item in schema_type if item != "null"]
|
|
394
|
+
return non_null[0] if non_null else "any"
|
|
395
|
+
if isinstance(schema_type, str):
|
|
396
|
+
return schema_type
|
|
397
|
+
any_of = definition.get("anyOf")
|
|
398
|
+
if isinstance(any_of, list):
|
|
399
|
+
for item in any_of:
|
|
400
|
+
if item.get("type") and item.get("type") != "null":
|
|
401
|
+
return str(item["type"])
|
|
402
|
+
return "any"
|
|
403
|
+
|
|
404
|
+
def _title_from_name(self, name: str) -> str:
|
|
405
|
+
return name.replace("_", " ").title()
|
|
406
|
+
|
|
407
|
+
async def write_catalog_json(self, output_path: Path) -> Path:
|
|
408
|
+
payload = await self.export_catalog()
|
|
409
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
410
|
+
output_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
|
411
|
+
return output_path
|
|
412
|
+
|
|
413
|
+
async def generate_python_wrappers(self, output_dir: Path) -> list[Path]:
|
|
414
|
+
catalog = await self.catalog_entries()
|
|
415
|
+
tool_definitions = await self.list_all_tools()
|
|
416
|
+
tools_by_name = {tool.name: tool for tool in tool_definitions}
|
|
417
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
418
|
+
root_package_dir = output_dir / self.WRAPPER_ROOT_PACKAGE
|
|
419
|
+
root_package_dir.mkdir(parents=True, exist_ok=True)
|
|
420
|
+
package_name = self.wrapper_namespace or self.domain
|
|
421
|
+
package_dir = root_package_dir / package_name
|
|
422
|
+
package_dir.mkdir(parents=True, exist_ok=True)
|
|
423
|
+
created_paths: list[Path] = []
|
|
424
|
+
root_init_path = root_package_dir / "__init__.py"
|
|
425
|
+
root_init_path.write_text(
|
|
426
|
+
"from __future__ import annotations\n\n"
|
|
427
|
+
"from .index import WRAPPER_INDEX\n\n"
|
|
428
|
+
'__all__ = ["WRAPPER_INDEX"]\n',
|
|
429
|
+
encoding="utf-8",
|
|
430
|
+
)
|
|
431
|
+
created_paths.append(root_init_path)
|
|
432
|
+
init_lines = ["from __future__ import annotations", "", "from .client import MCPToolCaller", ""]
|
|
433
|
+
client_path = package_dir / "client.py"
|
|
434
|
+
client_path.write_text(
|
|
435
|
+
"from __future__ import annotations\n\n"
|
|
436
|
+
"import json\n\n"
|
|
437
|
+
"from typing import Any, Protocol\n\n\n"
|
|
438
|
+
"class MCPToolCaller(Protocol):\n"
|
|
439
|
+
" async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: ...\n\n\n"
|
|
440
|
+
"def _coerce_tool_result(result: Any) -> Any:\n"
|
|
441
|
+
' structured = getattr(result, "structuredContent", None)\n'
|
|
442
|
+
" if structured is not None:\n"
|
|
443
|
+
" return structured\n"
|
|
444
|
+
' content = getattr(result, "content", None) or []\n'
|
|
445
|
+
" chunks: list[str] = []\n"
|
|
446
|
+
" for item in content:\n"
|
|
447
|
+
' text = getattr(item, "text", None)\n'
|
|
448
|
+
" if text:\n"
|
|
449
|
+
" chunks.append(text)\n"
|
|
450
|
+
" if not chunks:\n"
|
|
451
|
+
" return result\n"
|
|
452
|
+
' text_payload = "".join(chunks)\n'
|
|
453
|
+
" try:\n"
|
|
454
|
+
" return json.loads(text_payload)\n"
|
|
455
|
+
" except json.JSONDecodeError:\n"
|
|
456
|
+
" return text_payload\n\n\n"
|
|
457
|
+
"async def call_tool_json(client: MCPToolCaller, name: str, arguments: dict[str, Any]) -> Any:\n"
|
|
458
|
+
" result = await client.call_tool(name, arguments)\n"
|
|
459
|
+
" return _coerce_tool_result(result)\n",
|
|
460
|
+
encoding="utf-8",
|
|
461
|
+
)
|
|
462
|
+
created_paths.append(client_path)
|
|
463
|
+
index_entries: list[str] = []
|
|
464
|
+
exported_functions: list[str] = []
|
|
465
|
+
for entry in catalog:
|
|
466
|
+
if entry["name"] in self.discovery_tool_names:
|
|
467
|
+
continue
|
|
468
|
+
file_name = f"{entry['name']}.py"
|
|
469
|
+
function_name = self._safe_identifier(entry["name"])
|
|
470
|
+
module_path = package_dir / file_name
|
|
471
|
+
schema = tools_by_name[entry["name"]].input_schema
|
|
472
|
+
parameters_block, arguments_dict = self._python_wrapper_signature(schema)
|
|
473
|
+
example = entry["example_calls"][0]["arguments"] if entry["example_calls"] else {}
|
|
474
|
+
module_path.write_text(
|
|
475
|
+
self._render_wrapper_module(
|
|
476
|
+
function_name=function_name,
|
|
477
|
+
tool_name=entry["name"],
|
|
478
|
+
title=entry["title"],
|
|
479
|
+
description=entry["description"],
|
|
480
|
+
parameters_block=parameters_block,
|
|
481
|
+
arguments_dict=arguments_dict,
|
|
482
|
+
example=example,
|
|
483
|
+
),
|
|
484
|
+
encoding="utf-8",
|
|
485
|
+
)
|
|
486
|
+
created_paths.append(module_path)
|
|
487
|
+
init_lines.append(f"from .{entry['name']} import {function_name}")
|
|
488
|
+
index_entries.append(f' "{package_name}/{entry["name"]}": "{self.WRAPPER_ROOT_PACKAGE}/{package_name}/{file_name}",')
|
|
489
|
+
exported_functions.append(function_name)
|
|
490
|
+
init_lines.append("")
|
|
491
|
+
init_lines.append("__all__ = [")
|
|
492
|
+
init_lines.extend(f' "{name}",' for name in exported_functions)
|
|
493
|
+
init_lines.append("]")
|
|
494
|
+
init_path = package_dir / "__init__.py"
|
|
495
|
+
init_path.write_text("\n".join(init_lines) + "\n", encoding="utf-8")
|
|
496
|
+
created_paths.append(init_path)
|
|
497
|
+
index_path = root_package_dir / "index.py"
|
|
498
|
+
index_lines = ["from __future__ import annotations", "", "WRAPPER_INDEX = {"]
|
|
499
|
+
if index_path.exists():
|
|
500
|
+
existing = index_path.read_text(encoding="utf-8")
|
|
501
|
+
existing_entries = [line for line in existing.splitlines() if line.startswith(' "')]
|
|
502
|
+
index_lines.extend(existing_entries)
|
|
503
|
+
index_lines.extend(index_entries)
|
|
504
|
+
index_lines = list(dict.fromkeys(index_lines))
|
|
505
|
+
index_lines.append("}")
|
|
506
|
+
index_path.write_text("\n".join(index_lines) + "\n", encoding="utf-8")
|
|
507
|
+
created_paths.append(index_path)
|
|
508
|
+
return created_paths
|
|
509
|
+
|
|
510
|
+
def _python_wrapper_signature(self, schema: Mapping[str, Any]) -> tuple[str, str]:
|
|
511
|
+
properties = schema.get("properties", {}) if isinstance(schema, Mapping) else {}
|
|
512
|
+
required = set(schema.get("required", [])) if isinstance(schema, Mapping) else set()
|
|
513
|
+
parameters = ["client: MCPToolCaller"]
|
|
514
|
+
argument_lines: list[str] = []
|
|
515
|
+
for name, definition in properties.items():
|
|
516
|
+
annotation = self._python_annotation(definition)
|
|
517
|
+
safe_name = self._safe_identifier(name)
|
|
518
|
+
if name in required:
|
|
519
|
+
parameters.append(f"{safe_name}: {annotation}")
|
|
520
|
+
else:
|
|
521
|
+
parameters.append(f"{safe_name}: {annotation} | None = None")
|
|
522
|
+
argument_lines.append(f' "{name}": {safe_name},')
|
|
523
|
+
argument_dict = "\n".join(argument_lines)
|
|
524
|
+
return ",\n ".join(parameters), argument_dict
|
|
525
|
+
|
|
526
|
+
def _python_annotation(self, definition: Mapping[str, Any]) -> str:
|
|
527
|
+
if definition.get("enum"):
|
|
528
|
+
return "str"
|
|
529
|
+
schema_type = self._schema_type_name(definition)
|
|
530
|
+
return {
|
|
531
|
+
"string": "str",
|
|
532
|
+
"integer": "int",
|
|
533
|
+
"number": "float",
|
|
534
|
+
"boolean": "bool",
|
|
535
|
+
"array": "list[Any]",
|
|
536
|
+
"object": "dict[str, Any]",
|
|
537
|
+
}.get(schema_type, "Any")
|
|
538
|
+
|
|
539
|
+
def _render_wrapper_module(
|
|
540
|
+
self,
|
|
541
|
+
*,
|
|
542
|
+
function_name: str,
|
|
543
|
+
tool_name: str,
|
|
544
|
+
title: str,
|
|
545
|
+
description: str,
|
|
546
|
+
parameters_block: str,
|
|
547
|
+
arguments_dict: str,
|
|
548
|
+
example: Mapping[str, Any],
|
|
549
|
+
) -> str:
|
|
550
|
+
example_arguments = ", ".join(f"{self._safe_identifier(key)}={value!r}" for key, value in example.items())
|
|
551
|
+
return (
|
|
552
|
+
"from __future__ import annotations\n\n"
|
|
553
|
+
"from typing import Any\n\n"
|
|
554
|
+
"from .client import MCPToolCaller, call_tool_json\n\n\n"
|
|
555
|
+
f"async def {function_name}(\n"
|
|
556
|
+
f" {parameters_block}\n"
|
|
557
|
+
") -> Any:\n"
|
|
558
|
+
f' """{title}\n\n'
|
|
559
|
+
f" {description}\n\n"
|
|
560
|
+
f" Example:\n"
|
|
561
|
+
f" await {function_name}(client"
|
|
562
|
+
+ (f", {example_arguments}" if example_arguments else "")
|
|
563
|
+
+ ")\n"
|
|
564
|
+
' """\n'
|
|
565
|
+
" arguments = {\n"
|
|
566
|
+
f"{arguments_dict}\n"
|
|
567
|
+
" }\n"
|
|
568
|
+
" payload = {key: value for key, value in arguments.items() if value is not None}\n"
|
|
569
|
+
f' return await call_tool_json(client, "{tool_name}", payload)\n'
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def _safe_identifier(self, value: str) -> str:
|
|
573
|
+
identifier = re.sub(r"[^a-zA-Z0-9_]", "_", value)
|
|
574
|
+
if identifier and identifier[0].isdigit():
|
|
575
|
+
identifier = f"tool_{identifier}"
|
|
576
|
+
if keyword.iskeyword(identifier):
|
|
577
|
+
identifier = f"{identifier}_tool"
|
|
578
|
+
return identifier
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def install_search_first_discovery(
|
|
582
|
+
mcp: MCPServer,
|
|
583
|
+
*,
|
|
584
|
+
server_name: str,
|
|
585
|
+
domain: str,
|
|
586
|
+
list_all_tools: Callable[[], Awaitable[list[types.Tool]]] | None = None,
|
|
587
|
+
metadata: Mapping[str, ToolSearchMetadata] | None = None,
|
|
588
|
+
wrapper_namespace: str | None = None,
|
|
589
|
+
) -> SearchFirstDiscovery:
|
|
590
|
+
discovery = SearchFirstDiscovery(
|
|
591
|
+
mcp=mcp,
|
|
592
|
+
server_name=server_name,
|
|
593
|
+
domain=domain,
|
|
594
|
+
list_all_tools=list_all_tools or mcp.list_tools,
|
|
595
|
+
metadata=metadata or {},
|
|
596
|
+
wrapper_namespace=wrapper_namespace,
|
|
597
|
+
)
|
|
598
|
+
return discovery.register()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apple-mcp-common
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Shared discovery and generation helpers for the Apple MCP suite
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/JonathanRReed/Apple-MCPs
|
|
7
|
+
Project-URL: Repository, https://github.com/JonathanRReed/Apple-MCPs
|
|
8
|
+
Project-URL: Changelog, https://github.com/JonathanRReed/Apple-MCPs/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/JonathanRReed/Apple-MCPs/issues
|
|
10
|
+
Keywords: mcp,model-context-protocol,macos,apple,ai-agent,automation
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: MacOS X
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: mcp<3,>=2.0.0
|
|
24
|
+
Requires-Dist: pydantic>=2.12.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest<10,>=8; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff<1,>=0.12; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# AppleMCPCommon
|
|
31
|
+
|
|
32
|
+
Shared Python helpers for the Apple MCP suite.
|
|
33
|
+
|
|
34
|
+
This internal package provides:
|
|
35
|
+
|
|
36
|
+
- search-first tool discovery
|
|
37
|
+
- catalog export helpers
|
|
38
|
+
- generated wrapper helpers
|
|
39
|
+
- shared parsing helpers for deferred tool-call responses
|
|
40
|
+
- generated artifacts under `generated/tool_catalogs` and `generated/tool_wrappers/python`
|
|
41
|
+
|
|
42
|
+
It is not a user-facing MCP server.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/apple_mcp_common/__init__.py
|
|
5
|
+
src/apple_mcp_common/discovery.py
|
|
6
|
+
src/apple_mcp_common.egg-info/PKG-INFO
|
|
7
|
+
src/apple_mcp_common.egg-info/SOURCES.txt
|
|
8
|
+
src/apple_mcp_common.egg-info/dependency_links.txt
|
|
9
|
+
src/apple_mcp_common.egg-info/requires.txt
|
|
10
|
+
src/apple_mcp_common.egg-info/top_level.txt
|
|
11
|
+
tests/test_discovery.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
apple_mcp_common
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from mcp.server.mcpserver import MCPServer
|
|
7
|
+
from mcp.types import ToolAnnotations
|
|
8
|
+
|
|
9
|
+
from apple_mcp_common.discovery import install_search_first_discovery
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_install_search_first_discovery_exposes_full_tool_list() -> None:
|
|
13
|
+
mcp = MCPServer("Test")
|
|
14
|
+
|
|
15
|
+
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True), structured_output=True)
|
|
16
|
+
def test_health() -> dict[str, bool]:
|
|
17
|
+
return {"ok": True}
|
|
18
|
+
|
|
19
|
+
@mcp.tool(structured_output=True)
|
|
20
|
+
def test_write(value: str) -> dict[str, str]:
|
|
21
|
+
return {"value": value}
|
|
22
|
+
|
|
23
|
+
discovery = install_search_first_discovery(
|
|
24
|
+
mcp,
|
|
25
|
+
server_name="Test",
|
|
26
|
+
domain="files",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
async def load() -> tuple[set[str], dict[str, object]]:
|
|
30
|
+
tools = await mcp.list_tools()
|
|
31
|
+
info_result = await mcp.call_tool("get_tool_info", {"name": "test_write"})
|
|
32
|
+
return {tool.name for tool in tools}, info_result.structured_content
|
|
33
|
+
|
|
34
|
+
tool_names, info = asyncio.run(load())
|
|
35
|
+
|
|
36
|
+
assert tool_names == {"get_tool_info", "search_tools", "test_health", "test_write"}
|
|
37
|
+
assert info["ok"] is True
|
|
38
|
+
assert info["name"] == "test_write"
|
|
39
|
+
assert discovery.server_name == "Test"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_search_tools_returns_ranked_results() -> None:
|
|
43
|
+
mcp = MCPServer("Test")
|
|
44
|
+
|
|
45
|
+
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True), structured_output=True)
|
|
46
|
+
def files_search_files(query: str) -> dict[str, str]:
|
|
47
|
+
return {"query": query}
|
|
48
|
+
|
|
49
|
+
install_search_first_discovery(mcp, server_name="Test", domain="files")
|
|
50
|
+
|
|
51
|
+
async def run_search() -> dict[str, object]:
|
|
52
|
+
result = await mcp.call_tool("search_tools", {"query": "document", "limit": 3})
|
|
53
|
+
return result.structured_content
|
|
54
|
+
|
|
55
|
+
payload = asyncio.run(run_search())
|
|
56
|
+
|
|
57
|
+
assert payload["ok"] is True
|
|
58
|
+
assert payload["results"][0]["name"] == "files_search_files"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_search_tools_prefers_exact_domain_primitive() -> None:
|
|
62
|
+
mcp = MCPServer("Test")
|
|
63
|
+
|
|
64
|
+
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True), structured_output=True)
|
|
65
|
+
def mail_list_mailboxes() -> dict[str, bool]:
|
|
66
|
+
return {"ok": True}
|
|
67
|
+
|
|
68
|
+
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True), structured_output=True)
|
|
69
|
+
def apple_suggest_mailboxes() -> dict[str, bool]:
|
|
70
|
+
return {"ok": True}
|
|
71
|
+
|
|
72
|
+
@mcp.tool(structured_output=True)
|
|
73
|
+
def apple_preview_follow_up_from_mail() -> dict[str, bool]:
|
|
74
|
+
return {"ok": True}
|
|
75
|
+
|
|
76
|
+
install_search_first_discovery(mcp, server_name="Test", domain="apple")
|
|
77
|
+
|
|
78
|
+
async def run_search() -> dict[str, object]:
|
|
79
|
+
result = await mcp.call_tool("search_tools", {"query": "email mailbox", "limit": 3})
|
|
80
|
+
return result.structured_content
|
|
81
|
+
|
|
82
|
+
payload = asyncio.run(run_search())
|
|
83
|
+
|
|
84
|
+
assert payload["ok"] is True
|
|
85
|
+
assert payload["results"][0]["name"] == "mail_list_mailboxes"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_generate_python_wrappers_uses_namespaced_root(tmp_path: Path) -> None:
|
|
89
|
+
mcp = MCPServer("Test")
|
|
90
|
+
|
|
91
|
+
@mcp.tool(structured_output=True)
|
|
92
|
+
def calendar_list_events(start_iso: str, end_iso: str) -> dict[str, object]:
|
|
93
|
+
return {"ok": True, "start": start_iso, "end": end_iso}
|
|
94
|
+
|
|
95
|
+
discovery = install_search_first_discovery(mcp, server_name="Test", domain="calendar")
|
|
96
|
+
|
|
97
|
+
asyncio.run(discovery.generate_python_wrappers(tmp_path))
|
|
98
|
+
|
|
99
|
+
root_init = tmp_path / "apple_mcp_wrappers" / "__init__.py"
|
|
100
|
+
wrapper_path = tmp_path / "apple_mcp_wrappers" / "calendar" / "calendar_list_events.py"
|
|
101
|
+
client_path = tmp_path / "apple_mcp_wrappers" / "calendar" / "client.py"
|
|
102
|
+
|
|
103
|
+
assert root_init.exists()
|
|
104
|
+
assert wrapper_path.exists()
|
|
105
|
+
assert client_path.exists()
|
|
106
|
+
assert not (tmp_path / "calendar").exists()
|
|
107
|
+
assert "call_tool_json" in wrapper_path.read_text(encoding="utf-8")
|
|
108
|
+
assert "def _coerce_tool_result" in client_path.read_text(encoding="utf-8")
|