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 ADDED
@@ -0,0 +1,3 @@
1
+ Metadata-Version: 2.4
2
+ Name: simcpi
3
+ Version: 0.0.0
simcpi-0.0.0/README.md ADDED
File without changes
File without changes
simcpi-0.0.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .mcpserver import MCPApi
2
+ from .mcpclient import MCPClient
3
+
4
+ __all__ = ["MCPApi", "MCPClient"]
@@ -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,3 @@
1
+ Metadata-Version: 2.4
2
+ Name: simcpi
3
+ Version: 0.0.0
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ simcpi/__init__.py
4
+ simcpi/mcpclient.py
5
+ simcpi/mcpserver.py
6
+ simcpi.egg-info/PKG-INFO
7
+ simcpi.egg-info/SOURCES.txt
8
+ simcpi.egg-info/dependency_links.txt
9
+ simcpi.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ simcpi