py-easy-mcp 0.2.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.
easy_mcp/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ easy-mcp: Ultra-lightweight, zero-dependency Python framework for Model Context Protocol (MCP).
3
+ Build MCP servers for Claude in seconds.
4
+ """
5
+
6
+ from easy_mcp.server import EasyMCP
7
+ from easy_mcp.schema import function_to_tool_schema, function_to_prompt_arguments
8
+ from easy_mcp.protocol import (
9
+ MCPError,
10
+ ParseError,
11
+ InvalidRequestError,
12
+ MethodNotFoundError,
13
+ InvalidParamsError,
14
+ InternalError,
15
+ )
16
+
17
+ __version__ = "0.2.0"
18
+ __all__ = [
19
+ "EasyMCP",
20
+ "function_to_tool_schema",
21
+ "function_to_prompt_arguments",
22
+ "MCPError",
23
+ "ParseError",
24
+ "InvalidRequestError",
25
+ "MethodNotFoundError",
26
+ "InvalidParamsError",
27
+ "InternalError",
28
+ ]
easy_mcp/cli.py ADDED
@@ -0,0 +1,128 @@
1
+ """
2
+ CLI utilities for easy-mcp.
3
+ Scaffold starter servers, generate Claude Desktop config, and test tools locally.
4
+ """
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+
11
+ from easy_mcp import __version__
12
+
13
+ STARTER_TEMPLATE = '''"""
14
+ MCP Server generated with easy-mcp.
15
+ Connects with Claude Desktop and Claude Code.
16
+ """
17
+
18
+ from easy_mcp import EasyMCP
19
+
20
+ mcp = EasyMCP(name="{name}", version="0.1.0", description="Starter MCP Server")
21
+
22
+
23
+ @mcp.tool()
24
+ def hello_world(name: str = "World") -> str:
25
+ """Say hello to someone.
26
+
27
+ Args:
28
+ name: Name of the person to greet
29
+ """
30
+ return f"Hello, {{name}}! Welcome to MCP."
31
+
32
+
33
+ @mcp.tool()
34
+ def calculate(expression: str) -> str:
35
+ """Safely calculate a basic arithmetic expression.
36
+
37
+ Args:
38
+ expression: Simple math expression like '2 + 2 * 10'
39
+ """
40
+ allowed_chars = set("0123456789+-*/(). ")
41
+ if not all(c in allowed_chars for c in expression):
42
+ return "Error: Only basic numbers and operators (+, -, *, /) are allowed."
43
+ try:
44
+ result = eval(expression, {{"__builtins__": None}}, {{}})
45
+ return f"Result: {{result}}"
46
+ except Exception as e:
47
+ return f"Error evaluating expression: {{e}}"
48
+
49
+
50
+ if __name__ == "__main__":
51
+ # If run with --config, prints the Claude Desktop config JSON
52
+ if len(sys.argv) > 1 and sys.argv[1] == "--config":
53
+ mcp.print_claude_config()
54
+ else:
55
+ # Standard MCP stdio mode for Claude Desktop / Claude Code
56
+ mcp.run()
57
+ '''
58
+
59
+
60
+ def cmd_init(args: argparse.Namespace) -> int:
61
+ name = args.name or "my-mcp-server"
62
+ filename = args.output or "server.py"
63
+
64
+ if os.path.exists(filename) and not args.force:
65
+ print(f"Error: '{filename}' already exists. Use --force to overwrite.", file=sys.stderr)
66
+ return 1
67
+
68
+ content = STARTER_TEMPLATE.format(name=name)
69
+ with open(filename, "w", encoding="utf-8") as f:
70
+ f.write(content)
71
+
72
+ print(f"[easy-mcp] Created starter MCP server: {filename}")
73
+ print(f"\nNext steps:")
74
+ print(f"1. Test your server locally:")
75
+ print(f" python {filename} --config")
76
+ print(f"2. Add this server to your Claude Desktop config (claude_desktop_config.json)!")
77
+ return 0
78
+
79
+
80
+ def cmd_config(args: argparse.Namespace) -> int:
81
+ script_path = os.path.abspath(args.script)
82
+ if not os.path.isfile(script_path):
83
+ print(f"Error: Script '{args.script}' not found.", file=sys.stderr)
84
+ return 1
85
+
86
+ server_name = os.path.splitext(os.path.basename(script_path))[0]
87
+ cfg = {
88
+ "mcpServers": {
89
+ server_name: {
90
+ "command": "python",
91
+ "args": [script_path]
92
+ }
93
+ }
94
+ }
95
+ print(json.dumps(cfg, indent=2))
96
+ return 0
97
+
98
+
99
+ def main(argv=None) -> int:
100
+ parser = argparse.ArgumentParser(
101
+ prog="easy-mcp",
102
+ description="Fast, zero-dependency toolkit to build and configure MCP servers for Claude."
103
+ )
104
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
105
+ subparsers = parser.add_subparsers(dest="subcommand", help="Available subcommands")
106
+
107
+ # init
108
+ p_init = subparsers.add_parser("init", help="Scaffold a new starter MCP server file")
109
+ p_init.add_argument("name", nargs="?", default="my-tools", help="Name of your MCP server")
110
+ p_init.add_argument("-o", "--output", default="server.py", help="Output file path (default: server.py)")
111
+ p_init.add_argument("-f", "--force", action="store_true", help="Overwrite existing file")
112
+ p_init.set_defaults(func=cmd_init)
113
+
114
+ # config
115
+ p_cfg = subparsers.add_parser("config", help="Generate Claude Desktop config JSON for an existing script")
116
+ p_cfg.add_argument("script", help="Path to your python MCP server script")
117
+ p_cfg.set_defaults(func=cmd_config)
118
+
119
+ args = parser.parse_args(argv)
120
+ if not args.subcommand:
121
+ parser.print_help()
122
+ return 0
123
+
124
+ return args.func(args)
125
+
126
+
127
+ if __name__ == "__main__":
128
+ sys.exit(main())
easy_mcp/protocol.py ADDED
@@ -0,0 +1,92 @@
1
+ """
2
+ Model Context Protocol (MCP) and JSON-RPC 2.0 specifications.
3
+ Standard specification: https://spec.modelcontextprotocol.io/
4
+ Zero external dependencies.
5
+ """
6
+
7
+ from typing import Any, Dict, Optional
8
+
9
+ JSONRPC_VERSION = "2.0"
10
+ MCP_PROTOCOL_VERSION = "2024-11-05"
11
+
12
+ # Standard JSON-RPC Error Codes
13
+ PARSE_ERROR = -32700
14
+ INVALID_REQUEST = -32600
15
+ METHOD_NOT_FOUND = -32601
16
+ INVALID_PARAMS = -32602
17
+ INTERNAL_ERROR = -32603
18
+
19
+ # Standard MCP Methods
20
+ METHOD_INITIALIZE = "initialize"
21
+ METHOD_INITIALIZED = "notifications/initialized"
22
+ METHOD_PING = "ping"
23
+ METHOD_TOOLS_LIST = "tools/list"
24
+ METHOD_TOOLS_CALL = "tools/call"
25
+ METHOD_RESOURCES_LIST = "resources/list"
26
+ METHOD_RESOURCES_READ = "resources/read"
27
+ METHOD_PROMPTS_LIST = "prompts/list"
28
+ METHOD_PROMPTS_GET = "prompts/get"
29
+
30
+
31
+ class MCPError(Exception):
32
+ """Base exception for MCP and JSON-RPC errors."""
33
+
34
+ def __init__(self, code: int, message: str, data: Any = None):
35
+ super().__init__(message)
36
+ self.code = code
37
+ self.message = message
38
+ self.data = data
39
+
40
+
41
+ class ParseError(MCPError):
42
+ def __init__(self, message: str = "Parse error", data: Any = None):
43
+ super().__init__(PARSE_ERROR, message, data)
44
+
45
+
46
+ class InvalidRequestError(MCPError):
47
+ def __init__(self, message: str = "Invalid Request", data: Any = None):
48
+ super().__init__(INVALID_REQUEST, message, data)
49
+
50
+
51
+ class MethodNotFoundError(MCPError):
52
+ def __init__(self, message: str = "Method not found", data: Any = None):
53
+ super().__init__(METHOD_NOT_FOUND, message, data)
54
+
55
+
56
+ class InvalidParamsError(MCPError):
57
+ def __init__(self, message: str = "Invalid params", data: Any = None):
58
+ super().__init__(INVALID_PARAMS, message, data)
59
+
60
+
61
+ class InternalError(MCPError):
62
+ def __init__(self, message: str = "Internal error", data: Any = None):
63
+ super().__init__(INTERNAL_ERROR, message, data)
64
+
65
+
66
+ def make_jsonrpc_response(request_id: Any, result: Any) -> Dict[str, Any]:
67
+ """Build a compliant JSON-RPC 2.0 response."""
68
+ return {
69
+ "jsonrpc": JSONRPC_VERSION,
70
+ "id": request_id,
71
+ "result": result
72
+ }
73
+
74
+
75
+ def make_jsonrpc_error(
76
+ request_id: Any,
77
+ code: int,
78
+ message: str,
79
+ data: Optional[Any] = None
80
+ ) -> Dict[str, Any]:
81
+ """Build a compliant JSON-RPC 2.0 error response."""
82
+ err: Dict[str, Any] = {
83
+ "code": code,
84
+ "message": message
85
+ }
86
+ if data is not None:
87
+ err["data"] = data
88
+ return {
89
+ "jsonrpc": JSONRPC_VERSION,
90
+ "id": request_id,
91
+ "error": err
92
+ }
easy_mcp/schema.py ADDED
@@ -0,0 +1,158 @@
1
+ """
2
+ Automatic JSON Schema generator from Python function signatures and docstrings.
3
+ Zero external dependencies.
4
+ """
5
+
6
+ import inspect
7
+ import re
8
+ from typing import Any, Callable, Dict, List, Optional, Tuple, get_type_hints
9
+
10
+ TYPE_MAP = {
11
+ str: "string",
12
+ int: "integer",
13
+ float: "number",
14
+ bool: "boolean",
15
+ list: "array",
16
+ dict: "object",
17
+ }
18
+
19
+
20
+ def python_type_to_json_type(py_type: Any) -> str:
21
+ """Map Python types and type hints to JSON schema types."""
22
+ if py_type in TYPE_MAP:
23
+ return TYPE_MAP[py_type]
24
+
25
+ # Handle typing constructs or strings
26
+ type_str = str(py_type).lower()
27
+ if "str" in type_str:
28
+ return "string"
29
+ if "int" in type_str:
30
+ return "integer"
31
+ if "float" in type_str:
32
+ return "number"
33
+ if "bool" in type_str:
34
+ return "boolean"
35
+ if "list" in type_str or "sequence" in type_str:
36
+ return "array"
37
+ if "dict" in type_str or "mapping" in type_str:
38
+ return "object"
39
+
40
+ return "string"
41
+
42
+
43
+ def parse_docstring(docstring: Optional[str]) -> Tuple[str, Dict[str, str]]:
44
+ """
45
+ Extract main description and parameter descriptions from docstrings.
46
+ Supports Google, Sphinx, and standard docstrings.
47
+ """
48
+ if not docstring:
49
+ return "", {}
50
+
51
+ lines = docstring.strip().splitlines()
52
+ desc_lines = []
53
+ param_docs: Dict[str, str] = {}
54
+
55
+ in_args_section = False
56
+ current_param = None
57
+
58
+ for line in lines:
59
+ stripped = line.strip()
60
+ if re.match(r"^(Args|Parameters|Arguments):", stripped, re.IGNORECASE):
61
+ in_args_section = True
62
+ continue
63
+
64
+ if in_args_section:
65
+ # Check for param line: "param_name: description" or "param_name (type): description"
66
+ m = re.match(r"^([a-zA-Z_][a-zA-Z0-9_]*)(?:\s*\([^)]+\))?:\s*(.+)$", stripped)
67
+ if m:
68
+ current_param = m.group(1)
69
+ param_docs[current_param] = m.group(2).strip()
70
+ elif current_param and stripped:
71
+ # Continuation line
72
+ param_docs[current_param] += " " + stripped
73
+ else:
74
+ desc_lines.append(stripped)
75
+
76
+ main_desc = " ".join(desc_lines).strip()
77
+ return main_desc, param_docs
78
+
79
+
80
+ def function_to_tool_schema(
81
+ func: Callable,
82
+ name: Optional[str] = None,
83
+ description: Optional[str] = None
84
+ ) -> Dict[str, Any]:
85
+ """
86
+ Inspect a Python callable and convert it to an MCP tool definition with JSON Schema inputSchema.
87
+ """
88
+ tool_name = name or func.__name__
89
+ raw_doc = inspect.getdoc(func) or ""
90
+ main_desc, param_docs = parse_docstring(raw_doc)
91
+ tool_desc = description or main_desc or f"Tool: {tool_name}"
92
+
93
+ sig = inspect.signature(func)
94
+ try:
95
+ type_hints = get_type_hints(func)
96
+ except Exception:
97
+ type_hints = {}
98
+
99
+ properties: Dict[str, Any] = {}
100
+ required: List[str] = []
101
+
102
+ for param_name, param in sig.parameters.items():
103
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
104
+ continue
105
+
106
+ param_type = type_hints.get(param_name, str)
107
+ json_type = python_type_to_json_type(param_type)
108
+
109
+ prop_def: Dict[str, Any] = {"type": json_type}
110
+
111
+ if param_name in param_docs:
112
+ prop_def["description"] = param_docs[param_name]
113
+
114
+ if param.default is not inspect.Parameter.empty:
115
+ prop_def["default"] = param.default
116
+ else:
117
+ required.append(param_name)
118
+
119
+ properties[param_name] = prop_def
120
+
121
+ input_schema: Dict[str, Any] = {
122
+ "type": "object",
123
+ "properties": properties,
124
+ }
125
+ if required:
126
+ input_schema["required"] = required
127
+
128
+ return {
129
+ "name": tool_name,
130
+ "description": tool_desc,
131
+ "inputSchema": input_schema
132
+ }
133
+
134
+
135
+ def function_to_prompt_arguments(func: Callable) -> List[Dict[str, Any]]:
136
+ """
137
+ Inspect a Python callable and generate MCP prompt arguments list.
138
+ """
139
+ raw_doc = inspect.getdoc(func) or ""
140
+ _, param_docs = parse_docstring(raw_doc)
141
+
142
+ sig = inspect.signature(func)
143
+ arguments: List[Dict[str, Any]] = []
144
+
145
+ for param_name, param in sig.parameters.items():
146
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
147
+ continue
148
+
149
+ arg_def: Dict[str, Any] = {
150
+ "name": param_name,
151
+ "required": (param.default is inspect.Parameter.empty),
152
+ }
153
+ if param_name in param_docs:
154
+ arg_def["description"] = param_docs[param_name]
155
+
156
+ arguments.append(arg_def)
157
+
158
+ return arguments
easy_mcp/server.py ADDED
@@ -0,0 +1,444 @@
1
+ """
2
+ Core EasyMCP Server implementation.
3
+ Handles stdio JSON-RPC 2.0 communication with Claude Desktop / Claude Code.
4
+ Implements Model Context Protocol (MCP) 2024-11-05 Specification for Tools, Resources, and Prompts.
5
+ Zero external dependencies.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import sys
11
+ import traceback
12
+ from typing import Any, Callable, Dict, List, Optional
13
+
14
+ from easy_mcp.protocol import (
15
+ INTERNAL_ERROR,
16
+ INVALID_PARAMS,
17
+ INVALID_REQUEST,
18
+ MCP_PROTOCOL_VERSION,
19
+ METHOD_INITIALIZE,
20
+ METHOD_INITIALIZED,
21
+ METHOD_NOT_FOUND,
22
+ METHOD_PING,
23
+ METHOD_PROMPTS_GET,
24
+ METHOD_PROMPTS_LIST,
25
+ METHOD_RESOURCES_LIST,
26
+ METHOD_RESOURCES_READ,
27
+ METHOD_TOOLS_CALL,
28
+ METHOD_TOOLS_LIST,
29
+ PARSE_ERROR,
30
+ make_jsonrpc_error,
31
+ make_jsonrpc_response,
32
+ )
33
+ from easy_mcp.schema import function_to_prompt_arguments, function_to_tool_schema
34
+
35
+
36
+ class EasyMCP:
37
+ """
38
+ Lightweight, zero-dependency Model Context Protocol (MCP) Server.
39
+
40
+ Example:
41
+ mcp = EasyMCP("my-server", version="0.2.0")
42
+
43
+ @mcp.tool()
44
+ def add(a: float, b: float) -> float:
45
+ '''Add two numbers together.'''
46
+ return a + b
47
+
48
+ @mcp.resource("memo://notes")
49
+ def get_notes() -> str:
50
+ '''System documentation notes.'''
51
+ return "Server documentation and quick guides."
52
+
53
+ @mcp.prompt()
54
+ def code_review(code: str) -> str:
55
+ '''Review code for safety and style.'''
56
+ return f"Please review this code for security issues:\n\n{code}"
57
+
58
+ if __name__ == "__main__":
59
+ mcp.run()
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ name: str = "easy-mcp-server",
65
+ version: str = "0.2.0",
66
+ description: str = ""
67
+ ):
68
+ self.name = name
69
+ self.version = version
70
+ self.description = description
71
+
72
+ self._tools: Dict[str, Callable] = {}
73
+ self._tool_schemas: Dict[str, Dict[str, Any]] = {}
74
+
75
+ self._resources: Dict[str, Callable] = {}
76
+ self._resource_meta: Dict[str, Dict[str, Any]] = {}
77
+
78
+ self._prompts: Dict[str, Callable] = {}
79
+ self._prompt_meta: Dict[str, Dict[str, Any]] = {}
80
+
81
+ # ==========================================
82
+ # Tools API
83
+ # ==========================================
84
+
85
+ def tool(self, name: Optional[str] = None, description: Optional[str] = None):
86
+ """Decorator to register a Python function as an MCP tool."""
87
+ def decorator(func: Callable):
88
+ self.register_tool(func, name=name, description=description)
89
+ return func
90
+ return decorator
91
+
92
+ def register_tool(
93
+ self,
94
+ func: Callable,
95
+ name: Optional[str] = None,
96
+ description: Optional[str] = None
97
+ ):
98
+ """Programmatic registration of an MCP tool."""
99
+ tool_name = name or func.__name__
100
+ schema = function_to_tool_schema(func, name=tool_name, description=description)
101
+ self._tools[tool_name] = func
102
+ self._tool_schemas[tool_name] = schema
103
+
104
+ def list_tools(self) -> List[Dict[str, Any]]:
105
+ """Return list of registered MCP tools."""
106
+ return list(self._tool_schemas.values())
107
+
108
+ def call_tool(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
109
+ """Execute a tool locally and return MCP content block result."""
110
+ if name not in self._tools:
111
+ raise KeyError(f"Tool '{name}' not found.")
112
+
113
+ args = arguments or {}
114
+ func = self._tools[name]
115
+
116
+ try:
117
+ res = func(**args)
118
+
119
+ if isinstance(res, (dict, list)):
120
+ text_content = json.dumps(res, ensure_ascii=False, indent=2)
121
+ else:
122
+ text_content = str(res)
123
+
124
+ return {
125
+ "content": [
126
+ {
127
+ "type": "text",
128
+ "text": text_content
129
+ }
130
+ ],
131
+ "isError": False
132
+ }
133
+ except Exception as e:
134
+ return {
135
+ "content": [
136
+ {
137
+ "type": "text",
138
+ "text": f"Error executing tool '{name}': {str(e)}\n{traceback.format_exc()}"
139
+ }
140
+ ],
141
+ "isError": True
142
+ }
143
+
144
+ # ==========================================
145
+ # Resources API
146
+ # ==========================================
147
+
148
+ def resource(
149
+ self,
150
+ uri: str,
151
+ name: Optional[str] = None,
152
+ description: Optional[str] = None,
153
+ mime_type: str = "text/plain"
154
+ ):
155
+ """Decorator to register a function as an MCP resource provider."""
156
+ def decorator(func: Callable):
157
+ self.register_resource(
158
+ uri=uri,
159
+ func=func,
160
+ name=name,
161
+ description=description,
162
+ mime_type=mime_type
163
+ )
164
+ return func
165
+ return decorator
166
+
167
+ def register_resource(
168
+ self,
169
+ uri: str,
170
+ func: Callable,
171
+ name: Optional[str] = None,
172
+ description: Optional[str] = None,
173
+ mime_type: str = "text/plain"
174
+ ):
175
+ """Programmatic registration of an MCP resource."""
176
+ res_name = name or uri
177
+ doc = (func.__doc__ or "").strip()
178
+ res_desc = description or doc or f"Resource: {res_name}"
179
+
180
+ self._resources[uri] = func
181
+ self._resource_meta[uri] = {
182
+ "uri": uri,
183
+ "name": res_name,
184
+ "description": res_desc,
185
+ "mimeType": mime_type,
186
+ }
187
+
188
+ def list_resources(self) -> List[Dict[str, Any]]:
189
+ """Return list of registered MCP resources."""
190
+ return list(self._resource_meta.values())
191
+
192
+ def read_resource(self, uri: str) -> Dict[str, Any]:
193
+ """Read an MCP resource by URI."""
194
+ if uri not in self._resources:
195
+ raise KeyError(f"Resource '{uri}' not found.")
196
+
197
+ meta = self._resource_meta[uri]
198
+ func = self._resources[uri]
199
+
200
+ # Function may take 0 or 1 argument (uri)
201
+ import inspect
202
+ sig = inspect.signature(func)
203
+ if len(sig.parameters) == 1:
204
+ data = func(uri)
205
+ else:
206
+ data = func()
207
+
208
+ if isinstance(data, (dict, list)):
209
+ text_data = json.dumps(data, ensure_ascii=False, indent=2)
210
+ else:
211
+ text_data = str(data)
212
+
213
+ return {
214
+ "contents": [
215
+ {
216
+ "uri": uri,
217
+ "mimeType": meta.get("mimeType", "text/plain"),
218
+ "text": text_data
219
+ }
220
+ ]
221
+ }
222
+
223
+ # ==========================================
224
+ # Prompts API
225
+ # ==========================================
226
+
227
+ def prompt(
228
+ self,
229
+ name: Optional[str] = None,
230
+ description: Optional[str] = None,
231
+ arguments: Optional[List[Dict[str, Any]]] = None
232
+ ):
233
+ """Decorator to register a prompt template generator."""
234
+ def decorator(func: Callable):
235
+ self.register_prompt(
236
+ func=func,
237
+ name=name,
238
+ description=description,
239
+ arguments=arguments
240
+ )
241
+ return func
242
+ return decorator
243
+
244
+ def register_prompt(
245
+ self,
246
+ func: Callable,
247
+ name: Optional[str] = None,
248
+ description: Optional[str] = None,
249
+ arguments: Optional[List[Dict[str, Any]]] = None
250
+ ):
251
+ """Programmatic registration of an MCP prompt."""
252
+ prompt_name = name or func.__name__
253
+ doc = (func.__doc__ or "").strip()
254
+ prompt_desc = description or doc or f"Prompt: {prompt_name}"
255
+ args_list = arguments if arguments is not None else function_to_prompt_arguments(func)
256
+
257
+ self._prompts[prompt_name] = func
258
+ self._prompt_meta[prompt_name] = {
259
+ "name": prompt_name,
260
+ "description": prompt_desc,
261
+ "arguments": args_list
262
+ }
263
+
264
+ def list_prompts(self) -> List[Dict[str, Any]]:
265
+ """Return list of registered MCP prompts."""
266
+ return list(self._prompt_meta.values())
267
+
268
+ def get_prompt(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
269
+ """Execute a prompt template and return MCP prompt response."""
270
+ if name not in self._prompts:
271
+ raise KeyError(f"Prompt '{name}' not found.")
272
+
273
+ meta = self._prompt_meta[name]
274
+ func = self._prompts[name]
275
+ args = arguments or {}
276
+
277
+ res = func(**args)
278
+
279
+ # Normalize result into list of messages
280
+ if isinstance(res, list):
281
+ messages = res
282
+ elif isinstance(res, str):
283
+ messages = [
284
+ {
285
+ "role": "user",
286
+ "content": {
287
+ "type": "text",
288
+ "text": res
289
+ }
290
+ }
291
+ ]
292
+ elif isinstance(res, dict) and "messages" in res:
293
+ return res
294
+ else:
295
+ messages = [
296
+ {
297
+ "role": "user",
298
+ "content": {
299
+ "type": "text",
300
+ "text": str(res)
301
+ }
302
+ }
303
+ ]
304
+
305
+ return {
306
+ "description": meta.get("description", ""),
307
+ "messages": messages
308
+ }
309
+
310
+ # ==========================================
311
+ # JSON-RPC Request Dispatcher
312
+ # ==========================================
313
+
314
+ def handle_request(self, request_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
315
+ """Handle a single JSON-RPC 2.0 request."""
316
+ if not isinstance(request_data, dict):
317
+ return make_jsonrpc_error(None, INVALID_REQUEST, "Invalid JSON-RPC request structure.")
318
+
319
+ req_id = request_data.get("id")
320
+ method = request_data.get("method")
321
+ params = request_data.get("params", {})
322
+ is_notification = (req_id is None)
323
+
324
+ if not method or not isinstance(method, str):
325
+ return make_jsonrpc_error(req_id, INVALID_REQUEST, "Missing or invalid 'method' parameter.")
326
+
327
+ # 1. Initialize
328
+ if method == METHOD_INITIALIZE:
329
+ result = {
330
+ "protocolVersion": MCP_PROTOCOL_VERSION,
331
+ "capabilities": {
332
+ "tools": {},
333
+ "resources": {},
334
+ "prompts": {}
335
+ },
336
+ "serverInfo": {
337
+ "name": self.name,
338
+ "version": self.version
339
+ }
340
+ }
341
+ return make_jsonrpc_response(req_id, result)
342
+
343
+ elif method == METHOD_INITIALIZED:
344
+ return None
345
+
346
+ # 2. Ping
347
+ elif method == METHOD_PING:
348
+ return make_jsonrpc_response(req_id, {})
349
+
350
+ # 3. Tools
351
+ elif method == METHOD_TOOLS_LIST:
352
+ return make_jsonrpc_response(req_id, {"tools": self.list_tools()})
353
+
354
+ elif method == METHOD_TOOLS_CALL:
355
+ tool_name = params.get("name")
356
+ arguments = params.get("arguments", {})
357
+ if not tool_name:
358
+ return make_jsonrpc_error(req_id, INVALID_PARAMS, "Missing required parameter: 'name'")
359
+ if tool_name not in self._tools:
360
+ return make_jsonrpc_error(req_id, METHOD_NOT_FOUND, f"Tool '{tool_name}' not found.")
361
+
362
+ res = self.call_tool(tool_name, arguments)
363
+ return make_jsonrpc_response(req_id, res)
364
+
365
+ # 4. Resources
366
+ elif method == METHOD_RESOURCES_LIST:
367
+ return make_jsonrpc_response(req_id, {"resources": self.list_resources()})
368
+
369
+ elif method == METHOD_RESOURCES_READ:
370
+ uri = params.get("uri")
371
+ if not uri:
372
+ return make_jsonrpc_error(req_id, INVALID_PARAMS, "Missing required parameter: 'uri'")
373
+ if uri not in self._resources:
374
+ return make_jsonrpc_error(req_id, INVALID_PARAMS, f"Resource '{uri}' not found.")
375
+
376
+ try:
377
+ res = self.read_resource(uri)
378
+ return make_jsonrpc_response(req_id, res)
379
+ except Exception as e:
380
+ return make_jsonrpc_error(req_id, INTERNAL_ERROR, f"Error reading resource: {str(e)}")
381
+
382
+ # 5. Prompts
383
+ elif method == METHOD_PROMPTS_LIST:
384
+ return make_jsonrpc_response(req_id, {"prompts": self.list_prompts()})
385
+
386
+ elif method == METHOD_PROMPTS_GET:
387
+ prompt_name = params.get("name")
388
+ arguments = params.get("arguments", {})
389
+ if not prompt_name:
390
+ return make_jsonrpc_error(req_id, INVALID_PARAMS, "Missing required parameter: 'name'")
391
+ if prompt_name not in self._prompts:
392
+ return make_jsonrpc_error(req_id, METHOD_NOT_FOUND, f"Prompt '{prompt_name}' not found.")
393
+
394
+ try:
395
+ res = self.get_prompt(prompt_name, arguments)
396
+ return make_jsonrpc_response(req_id, res)
397
+ except Exception as e:
398
+ return make_jsonrpc_error(req_id, INTERNAL_ERROR, f"Error generating prompt: {str(e)}")
399
+
400
+ else:
401
+ if is_notification:
402
+ return None
403
+ return make_jsonrpc_error(req_id, METHOD_NOT_FOUND, f"Unknown method: '{method}'")
404
+
405
+ def run_stdio(self):
406
+ """Run the stdio JSON-RPC loop connecting with Claude Desktop."""
407
+ for line in sys.stdin:
408
+ line = line.strip()
409
+ if not line:
410
+ continue
411
+
412
+ try:
413
+ request_data = json.loads(line)
414
+ except json.JSONDecodeError as e:
415
+ err_resp = make_jsonrpc_error(None, PARSE_ERROR, f"JSON parse error: {str(e)}")
416
+ sys.stdout.write(json.dumps(err_resp) + "\n")
417
+ sys.stdout.flush()
418
+ continue
419
+
420
+ response = self.handle_request(request_data)
421
+ if response is not None:
422
+ sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
423
+ sys.stdout.flush()
424
+
425
+ def run(self):
426
+ """Alias for run_stdio."""
427
+ self.run_stdio()
428
+
429
+ def get_claude_config(self, script_path: Optional[str] = None) -> Dict[str, Any]:
430
+ """Generate the JSON snippet for claude_desktop_config.json."""
431
+ target_script = script_path or os.path.abspath(sys.argv[0])
432
+ return {
433
+ "mcpServers": {
434
+ self.name: {
435
+ "command": "python",
436
+ "args": [target_script]
437
+ }
438
+ }
439
+ }
440
+
441
+ def print_claude_config(self, script_path: Optional[str] = None):
442
+ """Print formatted JSON for Claude Desktop configuration."""
443
+ cfg = self.get_claude_config(script_path)
444
+ print(json.dumps(cfg, indent=2))
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.5
2
+ Name: py-easy-mcp
3
+ Version: 0.2.0
4
+ Summary: Ultra-lightweight, zero-dependency Python framework to build Model Context Protocol (MCP) servers for Claude.
5
+ Project-URL: Homepage, https://github.com/lui01212/easy-mcp
6
+ Project-URL: Repository, https://github.com/lui01212/easy-mcp.git
7
+ Project-URL: Issues, https://github.com/lui01212/easy-mcp/issues
8
+ Author-email: lui01212 <luynbv@glotechvn.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent,ai,anthropic,claude,json-rpc,llm,mcp,model-context-protocol,tools
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
25
+ Requires-Python: >=3.8
26
+ Description-Content-Type: text/markdown
27
+
28
+ # py-easy-mcp ⚡🤖
29
+
30
+ [![PyPI version](https://img.shields.io/pypi/v/py-easy-mcp.svg)](https://pypi.org/project/py-easy-mcp/)
31
+ [![Python versions](https://img.shields.io/pypi/pyversions/py-easy-mcp.svg)](https://pypi.org/project/py-easy-mcp/)
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
33
+ [![Tests](https://github.com/lui01212/easy-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/lui01212/easy-mcp/actions)
34
+ [![good first issues](https://img.shields.io/github/issues/lui01212/easy-mcp/good%20first%20issue?label=good%20first%20issues&color=7057ff)](https://github.com/lui01212/easy-mcp/issues?q=is%3Aissue+state%3Aopen+label%3A%22good+first+issue%22)
35
+ [![Hacktoberfest](https://img.shields.io/badge/Hacktoberfest-2024-ff7a59?logo=hacktoberfest)](https://hacktoberfest.com/)
36
+
37
+ **Ultra-lightweight, zero-dependency Python framework for Model Context Protocol (MCP). Build MCP servers for Claude in seconds.**
38
+
39
+ Turn standard Python functions into Model Context Protocol tools, resources, and prompt templates for **Claude Desktop** and **Claude Code** with simple decorators.
40
+
41
+ ---
42
+
43
+ ## ⚡ Why easy-mcp?
44
+
45
+ - **Zero dependencies:** Written in 100% pure standard Python. No heavy ASGI servers, no complex async overhead.
46
+ - **Full MCP Protocol Support (v0.2.0):** Supports Tools (`@mcp.tool()`), Resources (`@mcp.resource()`), and Prompt Templates (`@mcp.prompt()`).
47
+ - **Pythonic & Simple:** Turn any Python function into an MCP primitive with clean decorators.
48
+ - **Automatic Schema Inspection:** Docstrings and type annotations are automatically converted into standard JSON Schemas.
49
+ - **Claude Desktop Ready:** Built-in config generator outputs the exact JSON configuration to paste into `claude_desktop_config.json`.
50
+ - **Offline & Safe:** Standard I/O (stdio) JSON-RPC 2.0 communication.
51
+
52
+ ---
53
+
54
+ ## 📦 Installation
55
+
56
+ ```bash
57
+ pip install py-easy-mcp
58
+ ```
59
+
60
+ ---
61
+
62
+ ## 🚀 30-Second Quickstart
63
+
64
+ ### 1. Write your server (`my_server.py`)
65
+
66
+ ```python
67
+ from easy_mcp import EasyMCP
68
+
69
+ mcp = EasyMCP(name="my-server", version="0.2.0")
70
+
71
+ # 1. Register a Tool
72
+ @mcp.tool()
73
+ def add(a: float, b: float) -> float:
74
+ """Add two numbers together.
75
+
76
+ Args:
77
+ a: First number
78
+ b: Second number
79
+ """
80
+ return a + b
81
+
82
+ # 2. Register a Resource (readable context)
83
+ @mcp.resource("memo://project-guidelines")
84
+ def guidelines() -> str:
85
+ """Project engineering principles."""
86
+ return "1. Keep it simple.\n2. Zero external dependencies.\n3. Write clear tests."
87
+
88
+ # 3. Register a Prompt Template
89
+ @mcp.prompt()
90
+ def code_review(code: str, language: str = "python") -> str:
91
+ """Review code for bugs and maintainability."""
92
+ return f"Please review this {language} snippet:\n\n{code}"
93
+
94
+ if __name__ == "__main__":
95
+ mcp.run()
96
+ ```
97
+
98
+ ### 2. Add to Claude Desktop
99
+
100
+ Generate the configuration snippet:
101
+
102
+ ```bash
103
+ easy-mcp config my_server.py
104
+ ```
105
+
106
+ Output:
107
+ ```json
108
+ {
109
+ "mcpServers": {
110
+ "my-server": {
111
+ "command": "python",
112
+ "args": [
113
+ "/path/to/my_server.py"
114
+ ]
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
120
+ Paste this into your `claude_desktop_config.json`:
121
+ - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
122
+ - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
123
+
124
+ Restart Claude Desktop, and your tools, resources, and prompts will appear!
125
+
126
+ ---
127
+
128
+ ## 🛠️ CLI Utilities
129
+
130
+ Scaffold a new server in 1 second:
131
+ ```bash
132
+ easy-mcp init weather-server
133
+ ```
134
+
135
+ Generate config for Claude Desktop:
136
+ ```bash
137
+ easy-mcp config server.py
138
+ ```
139
+
140
+ ---
141
+
142
+ ## 📂 Community MCP Examples (Help Wanted!)
143
+
144
+ We are building a curated collection of lightweight MCP tool servers in `examples/`:
145
+ - `examples/basic_math.py` - Math calculator
146
+ - `examples/pii_shield_mcp.py` - AI data privacy sanitizer
147
+
148
+ ### Want to contribute?
149
+ Help us add more examples for Hacktoberfest:
150
+ - 🌐 SQLite database query tool
151
+ - 📰 Hacker News API reader
152
+ - 📁 Local markdown note searcher
153
+ - ⛅ Weather lookup
154
+
155
+ See [CONTRIBUTING.md](CONTRIBUTING.md) to submit an example!
156
+
157
+ ---
158
+
159
+ ## 📄 License
160
+
161
+ [MIT License](LICENSE) © 2026 lui01212
@@ -0,0 +1,10 @@
1
+ easy_mcp/__init__.py,sha256=bvChmmQxYG5S22OiNR0-bxoQ-kBEiPasKYJTELjdHhg,675
2
+ easy_mcp/cli.py,sha256=3wmh8ctsIoBM_HFFwFZ3OlZQwOCaeUK_9xXwEIVV9hA,3956
3
+ easy_mcp/protocol.py,sha256=P7x7oiCxxg-yppg6Qj3z7JU2JlP-84QhhM0imVKWR4Y,2521
4
+ easy_mcp/schema.py,sha256=dwzxMzV-rnqTJ2qPt8xve1PUzQNQuLJUNXR5YBBBr_c,4583
5
+ easy_mcp/server.py,sha256=wmanDyfy7eEe09Rqi9ytiiLfrLc_LVOpUGBCio_I9j4,14810
6
+ py_easy_mcp-0.2.0.dist-info/METADATA,sha256=qtdEhlsv8wPz7gxNBfgyppxRjTw16iu6sA8mqKhE2Ls,5314
7
+ py_easy_mcp-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ py_easy_mcp-0.2.0.dist-info/entry_points.txt,sha256=pmY6wFwySLtIsCCY3SkEs2EMXPq-AnyIoQE0sfksJQM,79
9
+ py_easy_mcp-0.2.0.dist-info/licenses/LICENSE,sha256=RWxza2uvDjCv6Rh2BuAIA6Y_trYT1WF7EZ5fUFYldWc,1065
10
+ py_easy_mcp-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ easy-mcp = easy_mcp.cli:main
3
+ py-easy-mcp = easy_mcp.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lui01212
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.