simcpi 0.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.
- simcpi-0.0.0/PKG-INFO +3 -0
- simcpi-0.0.0/README.md +0 -0
- simcpi-0.0.0/pyproject.toml +0 -0
- simcpi-0.0.0/setup.cfg +4 -0
- simcpi-0.0.0/simcpi/__init__.py +4 -0
- simcpi-0.0.0/simcpi/mcpclient.py +344 -0
- simcpi-0.0.0/simcpi/mcpserver.py +406 -0
- simcpi-0.0.0/simcpi.egg-info/PKG-INFO +3 -0
- simcpi-0.0.0/simcpi.egg-info/SOURCES.txt +9 -0
- simcpi-0.0.0/simcpi.egg-info/dependency_links.txt +1 -0
- simcpi-0.0.0/simcpi.egg-info/top_level.txt +1 -0
simcpi-0.0.0/PKG-INFO
ADDED
simcpi-0.0.0/README.md
ADDED
|
File without changes
|
|
File without changes
|
simcpi-0.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mcpclient.py — simcpi client
|
|
3
|
+
=============================
|
|
4
|
+
Standalone LLM + MCP client. Completely independent of mcpserver.py.
|
|
5
|
+
Point it at ANY MCP server — yours or someone else's.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
import asyncio
|
|
9
|
+
from mcpclient import MCPClient
|
|
10
|
+
|
|
11
|
+
client = MCPClient(
|
|
12
|
+
mcp_server="http://localhost:8000/mcp/mcp",
|
|
13
|
+
provider="openai",
|
|
14
|
+
api_key="sk-...",
|
|
15
|
+
)
|
|
16
|
+
result = asyncio.run(client.run("Add 42 and 58"))
|
|
17
|
+
print(result.answer)
|
|
18
|
+
print(result.tools_called)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Literal
|
|
27
|
+
|
|
28
|
+
# ── Dependency check ──────────────────────────────────────────────────────────
|
|
29
|
+
_REQUIRED = {
|
|
30
|
+
"fastmcp": "fastmcp>=3.0.0",
|
|
31
|
+
}
|
|
32
|
+
_missing = []
|
|
33
|
+
for _pkg, _spec in _REQUIRED.items():
|
|
34
|
+
try:
|
|
35
|
+
__import__(_pkg)
|
|
36
|
+
except ImportError:
|
|
37
|
+
_missing.append(_spec)
|
|
38
|
+
if _missing:
|
|
39
|
+
print("[simcpi] Missing dependencies. Run: pip install " + " ".join(_missing))
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
from fastmcp import Client as _MCPInternalClient
|
|
44
|
+
from fastmcp.client import StreamableHttpTransport
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
# Result types
|
|
49
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ToolCall:
|
|
53
|
+
"""A single tool invocation made by the LLM."""
|
|
54
|
+
name: str # tool name
|
|
55
|
+
arguments: dict # arguments the LLM passed
|
|
56
|
+
result: str # what the MCP server returned
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class MCPResult:
|
|
61
|
+
"""
|
|
62
|
+
Returned by MCPClient.run().
|
|
63
|
+
|
|
64
|
+
Attributes
|
|
65
|
+
----------
|
|
66
|
+
answer: Final text response from the LLM.
|
|
67
|
+
tools_called: Ordered list of every tool the LLM invoked.
|
|
68
|
+
success: False if an exception occurred.
|
|
69
|
+
error: Error message if success=False.
|
|
70
|
+
"""
|
|
71
|
+
answer: str
|
|
72
|
+
tools_called: list[ToolCall]
|
|
73
|
+
success: bool = True
|
|
74
|
+
error: str | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
# MCPClient
|
|
79
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
class MCPClient:
|
|
82
|
+
"""
|
|
83
|
+
Connects to any MCP server and wires it to an LLM.
|
|
84
|
+
The LLM sees the tools, calls them, and returns a final answer.
|
|
85
|
+
This is what Claude Desktop does internally — packaged as a Python function.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
mcp_server: MCP server URL e.g. "http://localhost:8000/mcp/mcp"
|
|
90
|
+
OR a FastMCP instance directly (for in-process use).
|
|
91
|
+
provider: "openai" or "anthropic"
|
|
92
|
+
api_key: Your LLM API key.
|
|
93
|
+
model: Model name. Defaults: gpt-4o / claude-sonnet-4-20250514.
|
|
94
|
+
system: Optional system prompt for every conversation.
|
|
95
|
+
timeout: HTTP timeout in seconds (default 30).
|
|
96
|
+
base_url: Custom LLM endpoint e.g. AICredits, OpenRouter.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
DEFAULTS = {
|
|
100
|
+
"openai": "gpt-4o",
|
|
101
|
+
"anthropic": "claude-sonnet-4-20250514",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
mcp_server: str | object,
|
|
107
|
+
provider: Literal["openai", "anthropic"],
|
|
108
|
+
api_key: str,
|
|
109
|
+
model: str | None = None,
|
|
110
|
+
system: str | None = None,
|
|
111
|
+
timeout: int = 30,
|
|
112
|
+
base_url: str | None = None,
|
|
113
|
+
):
|
|
114
|
+
self.mcp_server = mcp_server
|
|
115
|
+
self.provider = provider
|
|
116
|
+
self.api_key = api_key
|
|
117
|
+
self.model = model or self.DEFAULTS[provider]
|
|
118
|
+
self.system = system
|
|
119
|
+
self.timeout = timeout
|
|
120
|
+
self.base_url = base_url
|
|
121
|
+
|
|
122
|
+
# ── Transport ─────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def _transport(self):
|
|
125
|
+
"""
|
|
126
|
+
URL string → StreamableHttpTransport (real HTTP connection)
|
|
127
|
+
FastMCP obj → pass directly (in-process, no network needed)
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(self.mcp_server, str):
|
|
130
|
+
return StreamableHttpTransport(self.mcp_server)
|
|
131
|
+
return self.mcp_server
|
|
132
|
+
|
|
133
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
async def list_tools(self) -> list[dict]:
|
|
136
|
+
"""Fetch available tools from the MCP server."""
|
|
137
|
+
async with _MCPInternalClient(self._transport()) as client:
|
|
138
|
+
tools = await client.list_tools()
|
|
139
|
+
return [
|
|
140
|
+
{"name": t.name, "description": t.description, "inputSchema": t.inputSchema}
|
|
141
|
+
for t in tools
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
async def run(self, prompt: str, interpret: bool = True) -> MCPResult:
|
|
145
|
+
"""
|
|
146
|
+
Send a prompt, call MCP tools, return the result.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
prompt: The user message / question.
|
|
151
|
+
interpret: True — LLM calls tools AND explains the result in natural language.
|
|
152
|
+
False — tools are called, raw tool result returned, no LLM wrap.
|
|
153
|
+
Default: True.
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
MCPResult with .answer, .tools_called, .success, .error
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
tools = await self.list_tools()
|
|
161
|
+
if not interpret:
|
|
162
|
+
return await self._run_direct(prompt, tools)
|
|
163
|
+
if self.provider == "openai":
|
|
164
|
+
return await self._run_openai(prompt, tools)
|
|
165
|
+
else:
|
|
166
|
+
return await self._run_anthropic(prompt, tools)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
return MCPResult(answer="", tools_called=[], success=False, error=str(e))
|
|
169
|
+
|
|
170
|
+
# ── Internal ──────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
async def _call_tool(self, name: str, arguments: dict) -> str:
|
|
173
|
+
"""Execute one tool call on the MCP server and return the result."""
|
|
174
|
+
async with _MCPInternalClient(self._transport()) as client:
|
|
175
|
+
result = await client.call_tool(name, arguments)
|
|
176
|
+
return result.content[0].text if result.content else str(result)
|
|
177
|
+
|
|
178
|
+
async def _run_direct(self, prompt: str, mcp_tools: list[dict]) -> MCPResult:
|
|
179
|
+
"""
|
|
180
|
+
interpret=False path.
|
|
181
|
+
Ask the LLM to pick a tool and extract arguments only — no final explanation.
|
|
182
|
+
Execute the tool, return the raw result as .answer.
|
|
183
|
+
"""
|
|
184
|
+
if self.provider == "openai":
|
|
185
|
+
from openai import AsyncOpenAI
|
|
186
|
+
kw = dict(api_key=self.api_key, timeout=self.timeout)
|
|
187
|
+
if self.base_url:
|
|
188
|
+
kw["base_url"] = self.base_url
|
|
189
|
+
client = AsyncOpenAI(**kw)
|
|
190
|
+
|
|
191
|
+
oai_tools = [
|
|
192
|
+
{"type": "function", "function": {
|
|
193
|
+
"name": t["name"],
|
|
194
|
+
"description": t.get("description", ""),
|
|
195
|
+
"parameters": t.get("inputSchema", {"type": "object", "properties": {}}),
|
|
196
|
+
}}
|
|
197
|
+
for t in mcp_tools
|
|
198
|
+
]
|
|
199
|
+
messages: list = []
|
|
200
|
+
if self.system:
|
|
201
|
+
messages.append({"role": "system", "content": self.system})
|
|
202
|
+
messages.append({"role": "user", "content": prompt})
|
|
203
|
+
|
|
204
|
+
response = await client.chat.completions.create(
|
|
205
|
+
model=self.model, messages=messages,
|
|
206
|
+
tools=oai_tools, tool_choice="auto",
|
|
207
|
+
)
|
|
208
|
+
msg = response.choices[0].message
|
|
209
|
+
|
|
210
|
+
else: # anthropic
|
|
211
|
+
import anthropic as sdk
|
|
212
|
+
kw = dict(api_key=self.api_key, timeout=self.timeout)
|
|
213
|
+
if self.base_url:
|
|
214
|
+
kw["base_url"] = self.base_url
|
|
215
|
+
client = sdk.AsyncAnthropic(**kw)
|
|
216
|
+
|
|
217
|
+
ant_tools = [
|
|
218
|
+
{"name": t["name"], "description": t.get("description", ""),
|
|
219
|
+
"input_schema": t.get("inputSchema", {"type": "object", "properties": {}})}
|
|
220
|
+
for t in mcp_tools
|
|
221
|
+
]
|
|
222
|
+
kw2 = dict(model=self.model, max_tokens=256, messages=[{"role": "user", "content": prompt}],
|
|
223
|
+
tools=ant_tools)
|
|
224
|
+
if self.system:
|
|
225
|
+
kw2["system"] = self.system
|
|
226
|
+
response = await client.messages.create(**kw2)
|
|
227
|
+
|
|
228
|
+
# find first tool_use block
|
|
229
|
+
tool_block = next((b for b in response.content if b.type == "tool_use"), None)
|
|
230
|
+
if not tool_block:
|
|
231
|
+
text = " ".join(b.text for b in response.content if b.type == "text")
|
|
232
|
+
return MCPResult(answer=text, tools_called=[], success=True)
|
|
233
|
+
|
|
234
|
+
result = await self._call_tool(tool_block.name, tool_block.input)
|
|
235
|
+
return MCPResult(
|
|
236
|
+
answer=result,
|
|
237
|
+
tools_called=[ToolCall(name=tool_block.name, arguments=tool_block.input, result=result)],
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# openai path — check if tool was called
|
|
241
|
+
if not msg.tool_calls:
|
|
242
|
+
return MCPResult(answer=msg.content or "", tools_called=[])
|
|
243
|
+
|
|
244
|
+
tc = msg.tool_calls[0] # first tool only in direct mode
|
|
245
|
+
args = json.loads(tc.function.arguments)
|
|
246
|
+
result = await self._call_tool(tc.function.name, args)
|
|
247
|
+
return MCPResult(
|
|
248
|
+
answer=result,
|
|
249
|
+
tools_called=[ToolCall(name=tc.function.name, arguments=args, result=result)],
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async def _run_openai(self, prompt: str, mcp_tools: list[dict]) -> MCPResult:
|
|
253
|
+
from openai import AsyncOpenAI
|
|
254
|
+
|
|
255
|
+
kw = dict(api_key=self.api_key, timeout=self.timeout)
|
|
256
|
+
if self.base_url:
|
|
257
|
+
kw["base_url"] = self.base_url
|
|
258
|
+
client = AsyncOpenAI(**kw)
|
|
259
|
+
|
|
260
|
+
oai_tools = [
|
|
261
|
+
{"type": "function", "function": {
|
|
262
|
+
"name": t["name"],
|
|
263
|
+
"description": t.get("description", ""),
|
|
264
|
+
"parameters": t.get("inputSchema", {"type": "object", "properties": {}}),
|
|
265
|
+
}}
|
|
266
|
+
for t in mcp_tools
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
messages: list = []
|
|
270
|
+
if self.system:
|
|
271
|
+
messages.append({"role": "system", "content": self.system})
|
|
272
|
+
messages.append({"role": "user", "content": prompt})
|
|
273
|
+
tool_calls: list[ToolCall] = []
|
|
274
|
+
|
|
275
|
+
while True:
|
|
276
|
+
response = await client.chat.completions.create(
|
|
277
|
+
model=self.model,
|
|
278
|
+
messages=messages,
|
|
279
|
+
tools=oai_tools or None,
|
|
280
|
+
tool_choice="auto" if oai_tools else None,
|
|
281
|
+
)
|
|
282
|
+
msg = response.choices[0].message
|
|
283
|
+
messages.append(msg)
|
|
284
|
+
|
|
285
|
+
if msg.tool_calls:
|
|
286
|
+
results = []
|
|
287
|
+
for tc in msg.tool_calls:
|
|
288
|
+
args = json.loads(tc.function.arguments)
|
|
289
|
+
result = await self._call_tool(tc.function.name, args)
|
|
290
|
+
tool_calls.append(ToolCall(name=tc.function.name, arguments=args, result=result))
|
|
291
|
+
results.append({"tool_call_id": tc.id, "role": "tool", "content": result})
|
|
292
|
+
messages.extend(results)
|
|
293
|
+
else:
|
|
294
|
+
return MCPResult(answer=msg.content or "", tools_called=tool_calls)
|
|
295
|
+
|
|
296
|
+
async def _run_anthropic(self, prompt: str, mcp_tools: list[dict]) -> MCPResult:
|
|
297
|
+
import anthropic as sdk
|
|
298
|
+
|
|
299
|
+
kw = dict(api_key=self.api_key, timeout=self.timeout)
|
|
300
|
+
if self.base_url:
|
|
301
|
+
kw["base_url"] = self.base_url
|
|
302
|
+
client = sdk.AsyncAnthropic(**kw)
|
|
303
|
+
|
|
304
|
+
ant_tools = [
|
|
305
|
+
{"name": t["name"], "description": t.get("description", ""),
|
|
306
|
+
"input_schema": t.get("inputSchema", {"type": "object", "properties": {}})}
|
|
307
|
+
for t in mcp_tools
|
|
308
|
+
]
|
|
309
|
+
messages: list = [{"role": "user", "content": prompt}]
|
|
310
|
+
tool_calls: list[ToolCall] = []
|
|
311
|
+
|
|
312
|
+
while True:
|
|
313
|
+
kw2 = dict(model=self.model, max_tokens=1024, messages=messages)
|
|
314
|
+
if self.system:
|
|
315
|
+
kw2["system"] = self.system
|
|
316
|
+
if ant_tools:
|
|
317
|
+
kw2["tools"] = ant_tools
|
|
318
|
+
|
|
319
|
+
response = await client.messages.create(**kw2)
|
|
320
|
+
asst_content, tool_blocks = [], []
|
|
321
|
+
|
|
322
|
+
for block in response.content:
|
|
323
|
+
if block.type == "text":
|
|
324
|
+
asst_content.append({"type": "text", "text": block.text})
|
|
325
|
+
elif block.type == "tool_use":
|
|
326
|
+
asst_content.append({"type": "tool_use", "id": block.id,
|
|
327
|
+
"name": block.name, "input": block.input})
|
|
328
|
+
tool_blocks.append(block)
|
|
329
|
+
|
|
330
|
+
messages.append({"role": "assistant", "content": asst_content})
|
|
331
|
+
|
|
332
|
+
if tool_blocks:
|
|
333
|
+
results = []
|
|
334
|
+
for block in tool_blocks:
|
|
335
|
+
result = await self._call_tool(block.name, block.input)
|
|
336
|
+
tool_calls.append(ToolCall(name=block.name, arguments=block.input, result=result))
|
|
337
|
+
results.append({"type": "tool_result", "tool_use_id": block.id, "content": result})
|
|
338
|
+
messages.append({"role": "user", "content": results})
|
|
339
|
+
else:
|
|
340
|
+
final = " ".join(b["text"] for b in asst_content if b.get("type") == "text")
|
|
341
|
+
return MCPResult(
|
|
342
|
+
answer=tool_calls[-1].result if not final and tool_calls else final,
|
|
343
|
+
tools_called=tool_calls,
|
|
344
|
+
)
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mcpserver.py — simcpi server
|
|
3
|
+
============================
|
|
4
|
+
Single @app.create_tool_api() decorator → REST route + MCP tool.
|
|
5
|
+
|
|
6
|
+
Routes added automatically:
|
|
7
|
+
GET /docs Swagger UI
|
|
8
|
+
GET /mcpark MCPark — visual explorer + LLM tester
|
|
9
|
+
POST /mcpark/tools List registered MCP tools (internal)
|
|
10
|
+
POST /mcpark/run Run LLM+MCP loop (internal)
|
|
11
|
+
POST /mcp/mcp MCP streamable-http transport
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import inspect
|
|
18
|
+
import sys
|
|
19
|
+
from inspect import cleandoc
|
|
20
|
+
import functools
|
|
21
|
+
from contextlib import asynccontextmanager
|
|
22
|
+
from typing import Callable, Literal, get_type_hints
|
|
23
|
+
|
|
24
|
+
# ── Dependency check ──────────────────────────────────────────────────────────
|
|
25
|
+
_REQUIRED = {
|
|
26
|
+
"fastapi": "fastapi>=0.100.0",
|
|
27
|
+
"fastmcp": "fastmcp>=3.0.0",
|
|
28
|
+
"pydantic": "pydantic>=2.0.0",
|
|
29
|
+
"uvicorn": "uvicorn>=0.20.0",
|
|
30
|
+
}
|
|
31
|
+
_missing = []
|
|
32
|
+
for _pkg, _spec in _REQUIRED.items():
|
|
33
|
+
try:
|
|
34
|
+
__import__(_pkg)
|
|
35
|
+
except ImportError:
|
|
36
|
+
_missing.append(_spec)
|
|
37
|
+
if _missing:
|
|
38
|
+
print("[simcpi] Missing dependencies. Run: pip install " + " ".join(_missing))
|
|
39
|
+
sys.exit(1)
|
|
40
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
from fastapi import FastAPI, Request
|
|
43
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
44
|
+
from fastmcp import FastMCP
|
|
45
|
+
from fastmcp import Client as _MCPInternalClient
|
|
46
|
+
from fastmcp.client import StreamableHttpTransport
|
|
47
|
+
from pydantic import BaseModel
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
# MCPark HTML — served at /mcpark
|
|
54
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
html_path = Path(__file__).parent / "static" / "mcpark.html"
|
|
57
|
+
|
|
58
|
+
with open(html_path, "r", encoding="utf-8") as f:
|
|
59
|
+
MCPARK_HTML = f.read()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
# Pydantic models
|
|
64
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
class RunRequest(BaseModel):
|
|
67
|
+
prompt: str
|
|
68
|
+
api_key: str | None = None
|
|
69
|
+
provider: Literal["openai", "anthropic"] | None = None
|
|
70
|
+
model: str = "gpt-4o"
|
|
71
|
+
base_url: str | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
# MCPApi — the server class
|
|
76
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
class MCPApi:
|
|
79
|
+
"""
|
|
80
|
+
Merges FastAPI and FastMCP into a single server.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
title: API / MCP server name.
|
|
85
|
+
mcp_path: MCP transport prefix (default: "/mcp").
|
|
86
|
+
version: Version string.
|
|
87
|
+
description: Shown in OpenAPI docs and MCP server instructions.
|
|
88
|
+
mcpark_path: Path for MCPark UI (default: "/mcpark"). None to disable.
|
|
89
|
+
provider: Default LLM provider — "openai" or "anthropic".
|
|
90
|
+
api_key: Default LLM API key. Inherited by MCPark UI + get_client().
|
|
91
|
+
base_url: Default LLM base URL (for proxies like AICredits, OpenRouter).
|
|
92
|
+
model: Default model name. Shown pre-selected in MCPark.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
title: str = "simcpi",
|
|
98
|
+
mcp_path: str = "/mcp",
|
|
99
|
+
version: str = "0.1.0",
|
|
100
|
+
description: str | None = None,
|
|
101
|
+
mcpark_path: str | None = "/mcpark",
|
|
102
|
+
provider: Literal["openai", "anthropic"] | None = None,
|
|
103
|
+
api_key: str | None = None,
|
|
104
|
+
base_url: str | None = None,
|
|
105
|
+
model: str | None = None,
|
|
106
|
+
):
|
|
107
|
+
self.title = title
|
|
108
|
+
self.mcp_path = mcp_path.rstrip("/")
|
|
109
|
+
self.mcpark_path = mcpark_path
|
|
110
|
+
self.provider = provider
|
|
111
|
+
self.api_key = api_key
|
|
112
|
+
self.base_url = base_url
|
|
113
|
+
self.model = model
|
|
114
|
+
|
|
115
|
+
# ── FastMCP ──────────────────────────────────────────────────────────
|
|
116
|
+
self.mcp = FastMCP(name=title, version=version, instructions=description)
|
|
117
|
+
self._mcp_app = self.mcp.http_app()
|
|
118
|
+
|
|
119
|
+
# ── FastAPI + wired MCP lifespan ─────────────────────────────────────
|
|
120
|
+
mcp_app = self._mcp_app
|
|
121
|
+
|
|
122
|
+
@asynccontextmanager
|
|
123
|
+
async def combined_lifespan(app: FastAPI):
|
|
124
|
+
async with mcp_app.lifespan(mcp_app):
|
|
125
|
+
yield
|
|
126
|
+
|
|
127
|
+
self.api = FastAPI(
|
|
128
|
+
title=title,
|
|
129
|
+
version=version,
|
|
130
|
+
description=description or "",
|
|
131
|
+
lifespan=combined_lifespan,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
self.api.mount(self.mcp_path, self._mcp_app)
|
|
135
|
+
|
|
136
|
+
# ── Routes ───────────────────────────────────────────────────────────
|
|
137
|
+
self._register_mcpark()
|
|
138
|
+
|
|
139
|
+
# ── Registry ─────────────────────────────────────────────────────────
|
|
140
|
+
self._registry: dict[str, Callable] = {}
|
|
141
|
+
|
|
142
|
+
# ── MCPark routes ─────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
def _register_mcpark(self):
|
|
145
|
+
if not self.mcpark_path:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
path = self.mcpark_path
|
|
149
|
+
|
|
150
|
+
# Build server config dict — injected into the HTML so UI pre-fills
|
|
151
|
+
def _server_config(self=self) -> dict:
|
|
152
|
+
return {
|
|
153
|
+
"provider": self.provider or "openai",
|
|
154
|
+
"has_server_key": bool(self.api_key),
|
|
155
|
+
"base_url": self.base_url,
|
|
156
|
+
"model": self.model or "gpt-4o",
|
|
157
|
+
"mcp_path": self.mcp_path,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
@self.api.get(path, response_class=HTMLResponse, include_in_schema=False)
|
|
161
|
+
async def mcpark_ui():
|
|
162
|
+
import json as _json
|
|
163
|
+
config_json = _json.dumps(_server_config())
|
|
164
|
+
html = MCPARK_HTML.replace("__SERVER_CONFIG__", config_json)
|
|
165
|
+
return HTMLResponse(html)
|
|
166
|
+
|
|
167
|
+
@self.api.post(f"{path}/tools", include_in_schema=False)
|
|
168
|
+
async def mcpark_tools():
|
|
169
|
+
async with _MCPInternalClient(self.mcp) as client:
|
|
170
|
+
tools = await client.list_tools()
|
|
171
|
+
return JSONResponse({
|
|
172
|
+
"tools": [
|
|
173
|
+
{"name": t.name, "description": t.description, "inputSchema": t.inputSchema}
|
|
174
|
+
for t in tools
|
|
175
|
+
]
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
@self.api.post(f"{path}/run", include_in_schema=False)
|
|
179
|
+
async def mcpark_run(req: RunRequest, request: Request):
|
|
180
|
+
# Resolve — request body wins, instance defaults as fallback
|
|
181
|
+
resolved_provider = req.provider or self.provider
|
|
182
|
+
resolved_api_key = req.api_key or self.api_key
|
|
183
|
+
resolved_base_url = req.base_url or self.base_url
|
|
184
|
+
resolved_model = req.model or self.model or "gpt-4o"
|
|
185
|
+
|
|
186
|
+
if not resolved_provider or not resolved_api_key:
|
|
187
|
+
return JSONResponse({
|
|
188
|
+
"error": "provider and api_key are required — set on MCPApi() or pass in the UI",
|
|
189
|
+
"trace": [], "tools_called": False
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
base = str(request.base_url).rstrip("/")
|
|
193
|
+
mcp_url = f"{base}{self.mcp_path}/mcp"
|
|
194
|
+
|
|
195
|
+
# Fetch tools via real HTTP transport
|
|
196
|
+
transport = StreamableHttpTransport(mcp_url)
|
|
197
|
+
async with _MCPInternalClient(transport) as client:
|
|
198
|
+
raw_tools = await client.list_tools()
|
|
199
|
+
|
|
200
|
+
mcp_tools = [
|
|
201
|
+
{"name": t.name, "description": t.description, "inputSchema": t.inputSchema}
|
|
202
|
+
for t in raw_tools
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
trace: list[dict] = []
|
|
206
|
+
tools_called: bool = False
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
if resolved_provider == "openai":
|
|
210
|
+
from openai import AsyncOpenAI
|
|
211
|
+
kw = dict(api_key=resolved_api_key)
|
|
212
|
+
if resolved_base_url:
|
|
213
|
+
kw["base_url"] = resolved_base_url
|
|
214
|
+
oai = AsyncOpenAI(**kw)
|
|
215
|
+
|
|
216
|
+
oai_tools = [
|
|
217
|
+
{"type": "function", "function": {
|
|
218
|
+
"name": t["name"],
|
|
219
|
+
"description": t.get("description", ""),
|
|
220
|
+
"parameters": t.get("inputSchema", {"type": "object", "properties": {}}),
|
|
221
|
+
}}
|
|
222
|
+
for t in mcp_tools
|
|
223
|
+
]
|
|
224
|
+
messages = [{"role": "user", "content": req.prompt}]
|
|
225
|
+
|
|
226
|
+
while True:
|
|
227
|
+
resp = await oai.chat.completions.create(
|
|
228
|
+
model=resolved_model, messages=messages,
|
|
229
|
+
tools=oai_tools, tool_choice="auto",
|
|
230
|
+
)
|
|
231
|
+
msg = resp.choices[0].message
|
|
232
|
+
messages.append(msg)
|
|
233
|
+
|
|
234
|
+
if msg.tool_calls:
|
|
235
|
+
tools_called = True
|
|
236
|
+
results = []
|
|
237
|
+
for tc in msg.tool_calls:
|
|
238
|
+
import json as _j
|
|
239
|
+
args = _j.loads(tc.function.arguments)
|
|
240
|
+
trace.append({"type": "tool_call", "name": tc.function.name, "arguments": args})
|
|
241
|
+
async with _MCPInternalClient(StreamableHttpTransport(mcp_url)) as c:
|
|
242
|
+
r = await c.call_tool(tc.function.name, args)
|
|
243
|
+
txt = r.content[0].text if r.content else str(r)
|
|
244
|
+
trace.append({"type": "tool_result", "name": tc.function.name, "result": txt})
|
|
245
|
+
results.append({"tool_call_id": tc.id, "role": "tool", "content": txt})
|
|
246
|
+
messages.extend(results)
|
|
247
|
+
else:
|
|
248
|
+
if msg.content:
|
|
249
|
+
trace.append({"type": "message", "content": msg.content})
|
|
250
|
+
break
|
|
251
|
+
|
|
252
|
+
else: # anthropic
|
|
253
|
+
import anthropic as _sdk
|
|
254
|
+
kw = dict(api_key=resolved_api_key)
|
|
255
|
+
if resolved_base_url:
|
|
256
|
+
kw["base_url"] = resolved_base_url
|
|
257
|
+
ant = _sdk.AsyncAnthropic(**kw)
|
|
258
|
+
|
|
259
|
+
ant_tools = [
|
|
260
|
+
{"name": t["name"], "description": t.get("description", ""),
|
|
261
|
+
"input_schema": t.get("inputSchema", {"type": "object", "properties": {}})}
|
|
262
|
+
for t in mcp_tools
|
|
263
|
+
]
|
|
264
|
+
messages = [{"role": "user", "content": req.prompt}]
|
|
265
|
+
|
|
266
|
+
while True:
|
|
267
|
+
resp = await ant.messages.create(
|
|
268
|
+
model=resolved_model, max_tokens=1024,
|
|
269
|
+
tools=ant_tools, messages=messages,
|
|
270
|
+
)
|
|
271
|
+
asst_content, tool_blocks = [], []
|
|
272
|
+
for block in resp.content:
|
|
273
|
+
if block.type == "text":
|
|
274
|
+
asst_content.append({"type": "text", "text": block.text})
|
|
275
|
+
if block.text:
|
|
276
|
+
trace.append({"type": "message", "content": block.text})
|
|
277
|
+
elif block.type == "tool_use":
|
|
278
|
+
tools_called = True
|
|
279
|
+
asst_content.append({"type": "tool_use", "id": block.id,
|
|
280
|
+
"name": block.name, "input": block.input})
|
|
281
|
+
trace.append({"type": "tool_call", "name": block.name, "arguments": block.input})
|
|
282
|
+
tool_blocks.append(block)
|
|
283
|
+
|
|
284
|
+
messages.append({"role": "assistant", "content": asst_content})
|
|
285
|
+
|
|
286
|
+
if tool_blocks:
|
|
287
|
+
results = []
|
|
288
|
+
for block in tool_blocks:
|
|
289
|
+
async with _MCPInternalClient(StreamableHttpTransport(mcp_url)) as c:
|
|
290
|
+
r = await c.call_tool(block.name, block.input)
|
|
291
|
+
txt = r.content[0].text if r.content else str(r)
|
|
292
|
+
trace.append({"type": "tool_result", "name": block.name, "result": txt})
|
|
293
|
+
results.append({"type": "tool_result", "tool_use_id": block.id, "content": txt})
|
|
294
|
+
messages.append({"role": "user", "content": results})
|
|
295
|
+
else:
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
except Exception as e:
|
|
299
|
+
return JSONResponse({"error": str(e), "trace": trace, "tools_called": tools_called})
|
|
300
|
+
|
|
301
|
+
return JSONResponse({"trace": trace, "tools_called": tools_called})
|
|
302
|
+
|
|
303
|
+
# ── @create_tool_api decorator ────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
def create_tool_api(
|
|
306
|
+
self,
|
|
307
|
+
path: str,
|
|
308
|
+
*,
|
|
309
|
+
method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] = "POST",
|
|
310
|
+
tool_name: str | None = None,
|
|
311
|
+
tags: list[str] | None = None,
|
|
312
|
+
summary: str | None = None,
|
|
313
|
+
) -> Callable:
|
|
314
|
+
"""
|
|
315
|
+
Register a function as both a FastAPI REST endpoint and a FastMCP tool.
|
|
316
|
+
|
|
317
|
+
The function's docstring becomes the MCP tool description —
|
|
318
|
+
write it for the LLM, not just for humans.
|
|
319
|
+
|
|
320
|
+
Usage:
|
|
321
|
+
@app.create_tool_api("/add", method="POST")
|
|
322
|
+
def add(a: int, b: int) -> int:
|
|
323
|
+
\"\"\"
|
|
324
|
+
Add two numbers.
|
|
325
|
+
Use this when the user asks to sum or total numbers.
|
|
326
|
+
\"\"\"
|
|
327
|
+
return a + b
|
|
328
|
+
"""
|
|
329
|
+
def decorator(fn: Callable) -> Callable:
|
|
330
|
+
name = tool_name or fn.__name__
|
|
331
|
+
doc = cleandoc(fn.__doc__) if fn.__doc__ else ""
|
|
332
|
+
|
|
333
|
+
# 1. FastMCP tool
|
|
334
|
+
self.mcp.tool(name=name, description=doc)(fn)
|
|
335
|
+
|
|
336
|
+
# 2. FastAPI route
|
|
337
|
+
@functools.wraps(fn)
|
|
338
|
+
async def route_handler(*args, **kwargs):
|
|
339
|
+
if inspect.iscoroutinefunction(fn):
|
|
340
|
+
return await fn(*args, **kwargs)
|
|
341
|
+
return fn(*args, **kwargs)
|
|
342
|
+
|
|
343
|
+
route_handler.__annotations__ = get_type_hints(fn)
|
|
344
|
+
route_handler.__doc__ = doc
|
|
345
|
+
|
|
346
|
+
getattr(self.api, method.lower())(
|
|
347
|
+
path, summary=summary or name,
|
|
348
|
+
description=doc, tags=tags, name=name,
|
|
349
|
+
)(route_handler)
|
|
350
|
+
|
|
351
|
+
self._registry[name] = fn
|
|
352
|
+
return fn
|
|
353
|
+
|
|
354
|
+
return decorator
|
|
355
|
+
|
|
356
|
+
# ── ASGI passthrough ──────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
async def __call__(self, scope, receive, send):
|
|
359
|
+
await self.api(scope, receive, send)
|
|
360
|
+
|
|
361
|
+
# ── get_client — returns a wired MCPClient ────────────────────────────────
|
|
362
|
+
|
|
363
|
+
def get_client(
|
|
364
|
+
self,
|
|
365
|
+
provider: Literal["openai", "anthropic"] | None = None,
|
|
366
|
+
api_key: str | None = None,
|
|
367
|
+
model: str | None = None,
|
|
368
|
+
system: str | None = None,
|
|
369
|
+
timeout: int = 30,
|
|
370
|
+
base_url: str | None = None,
|
|
371
|
+
) -> "MCPClient":
|
|
372
|
+
"""
|
|
373
|
+
Return an MCPClient pre-wired to this server's MCP instance (in-process).
|
|
374
|
+
|
|
375
|
+
Falls back to provider/api_key/base_url/model set on MCPApi().
|
|
376
|
+
|
|
377
|
+
Usage:
|
|
378
|
+
app = MCPApi(provider="openai", api_key="sk-...")
|
|
379
|
+
client = app.get_client()
|
|
380
|
+
result = await client.run("Add 42 and 58")
|
|
381
|
+
"""
|
|
382
|
+
from .mcpclient import MCPClient
|
|
383
|
+
|
|
384
|
+
resolved_provider = provider or self.provider
|
|
385
|
+
resolved_api_key = api_key or self.api_key
|
|
386
|
+
resolved_base_url = base_url or self.base_url
|
|
387
|
+
resolved_model = model or self.model
|
|
388
|
+
|
|
389
|
+
if not resolved_provider:
|
|
390
|
+
raise ValueError("provider required — set on MCPApi() or pass to get_client()")
|
|
391
|
+
if not resolved_api_key:
|
|
392
|
+
raise ValueError("api_key required — set on MCPApi() or pass to get_client()")
|
|
393
|
+
|
|
394
|
+
return MCPClient(
|
|
395
|
+
mcp_server=self.mcp,
|
|
396
|
+
provider=resolved_provider,
|
|
397
|
+
api_key=resolved_api_key,
|
|
398
|
+
model=resolved_model,
|
|
399
|
+
system=system,
|
|
400
|
+
timeout=timeout,
|
|
401
|
+
base_url=resolved_base_url,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
def list_tools(self) -> list[str]:
|
|
405
|
+
"""Return names of all registered tools."""
|
|
406
|
+
return list(self._registry.keys())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
simcpi
|