devduck 0.1.0__py3-none-any.whl → 0.1.1766644714__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.

Potentially problematic release.


This version of devduck might be problematic. Click here for more details.

Files changed (37) hide show
  1. devduck/__init__.py +1439 -483
  2. devduck/__main__.py +7 -0
  3. devduck/_version.py +34 -0
  4. devduck/agentcore_handler.py +76 -0
  5. devduck/test_redduck.py +0 -1
  6. devduck/tools/__init__.py +47 -0
  7. devduck/tools/_ambient_input.py +423 -0
  8. devduck/tools/_tray_app.py +530 -0
  9. devduck/tools/agentcore_agents.py +197 -0
  10. devduck/tools/agentcore_config.py +441 -0
  11. devduck/tools/agentcore_invoke.py +423 -0
  12. devduck/tools/agentcore_logs.py +320 -0
  13. devduck/tools/ambient.py +157 -0
  14. devduck/tools/create_subagent.py +659 -0
  15. devduck/tools/fetch_github_tool.py +201 -0
  16. devduck/tools/install_tools.py +409 -0
  17. devduck/tools/ipc.py +546 -0
  18. devduck/tools/mcp_server.py +600 -0
  19. devduck/tools/scraper.py +935 -0
  20. devduck/tools/speech_to_speech.py +850 -0
  21. devduck/tools/state_manager.py +292 -0
  22. devduck/tools/store_in_kb.py +187 -0
  23. devduck/tools/system_prompt.py +608 -0
  24. devduck/tools/tcp.py +263 -94
  25. devduck/tools/tray.py +247 -0
  26. devduck/tools/use_github.py +438 -0
  27. devduck/tools/websocket.py +498 -0
  28. devduck-0.1.1766644714.dist-info/METADATA +717 -0
  29. devduck-0.1.1766644714.dist-info/RECORD +33 -0
  30. {devduck-0.1.0.dist-info → devduck-0.1.1766644714.dist-info}/entry_points.txt +1 -0
  31. devduck-0.1.1766644714.dist-info/licenses/LICENSE +201 -0
  32. devduck/install.sh +0 -42
  33. devduck-0.1.0.dist-info/METADATA +0 -106
  34. devduck-0.1.0.dist-info/RECORD +0 -11
  35. devduck-0.1.0.dist-info/licenses/LICENSE +0 -21
  36. {devduck-0.1.0.dist-info → devduck-0.1.1766644714.dist-info}/WHEEL +0 -0
  37. {devduck-0.1.0.dist-info → devduck-0.1.1766644714.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,600 @@
1
+ """MCP Server Tool for DevDuck.
2
+
3
+ Transforms DevDuck into an MCP (Model Context Protocol) server, exposing devduck
4
+ tools and capabilities to any MCP-compatible client (Claude Desktop, other agents, etc.).
5
+ """
6
+
7
+ import contextlib
8
+ import logging
9
+ import threading
10
+ import time
11
+ import traceback
12
+ from collections.abc import AsyncIterator
13
+ from typing import Any, Optional
14
+
15
+ from strands import tool
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # MCP imports with error handling
20
+ MCP_IMPORT_ERROR = ""
21
+ try:
22
+ import uvicorn
23
+ from mcp import types
24
+ from mcp.server.lowlevel import Server
25
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
26
+ from starlette.applications import Starlette
27
+ from starlette.middleware.cors import CORSMiddleware
28
+ from starlette.routing import Mount
29
+ from starlette.types import Receive, Scope, Send
30
+
31
+ HAS_MCP = True
32
+ except ImportError as e:
33
+ HAS_MCP = False
34
+ MCP_IMPORT_ERROR = str(e)
35
+
36
+
37
+ # Global state to track MCP servers
38
+ _server_state = {
39
+ "servers": {}, # Map of server_id -> server_info
40
+ "default_server": None, # ID of the default server
41
+ }
42
+
43
+
44
+ @tool
45
+ def mcp_server(
46
+ action: str,
47
+ server_id: str = "default",
48
+ transport: str = "http",
49
+ port: int = 8000,
50
+ tools: Optional[list[str]] = None,
51
+ expose_agent: bool = True,
52
+ stateless: bool = False,
53
+ agent: Any = None,
54
+ ) -> dict[str, Any]:
55
+ """Turn devduck into an MCP server, exposing tools as MCP tools.
56
+
57
+ Args:
58
+ action: Action to perform - "start", "stop", "status", "list"
59
+ server_id: Unique identifier for this server instance (default: "default")
60
+ transport: Transport type - "http" (StreamableHTTP, background) or "stdio" (foreground, blocking)
61
+ port: Port for HTTP server (only used when transport="http", default: 8000)
62
+ tools: Optional list of tool names to expose. If None, exposes all tools except mcp_server itself
63
+ expose_agent: Whether to expose "devduck" tool for full agent conversations (default: True)
64
+ stateless: If True, creates fresh transport per request with no session state (default: False)
65
+ agent: Parent agent instance (auto-injected by Strands framework)
66
+
67
+ Returns:
68
+ Result dictionary with status and content
69
+ """
70
+ try:
71
+ # Check if MCP is installed
72
+ if not HAS_MCP:
73
+ return {
74
+ "status": "error",
75
+ "content": [
76
+ {
77
+ "text": f"❌ MCP not installed: {MCP_IMPORT_ERROR}\n\n"
78
+ f"Install with: pip install strands-mcp-server"
79
+ }
80
+ ],
81
+ }
82
+
83
+ # Route to appropriate handler
84
+ if action == "start":
85
+ return _start_mcp_server(
86
+ server_id, transport, port, tools, expose_agent, stateless, agent
87
+ )
88
+ elif action == "stop":
89
+ return _stop_mcp_server(server_id)
90
+ elif action == "status":
91
+ return _get_mcp_status()
92
+ elif action == "list":
93
+ return _list_mcp_servers()
94
+ else:
95
+ return {
96
+ "status": "error",
97
+ "content": [
98
+ {
99
+ "text": f"❌ Unknown action: {action}\n\nValid actions: start, stop, status, list"
100
+ }
101
+ ],
102
+ }
103
+
104
+ except Exception as e:
105
+ logger.exception("MCP server tool error")
106
+ return {
107
+ "status": "error",
108
+ "content": [{"text": f"❌ Error: {str(e)}\n\n{traceback.format_exc()}"}],
109
+ }
110
+
111
+
112
+ def _start_mcp_server(
113
+ server_id: str,
114
+ transport: str,
115
+ port: int,
116
+ tools_filter: Optional[list[str]],
117
+ expose_agent: bool,
118
+ stateless: bool,
119
+ agent: Any,
120
+ ) -> dict[str, Any]:
121
+ """Start an MCP server exposing devduck tools."""
122
+ if server_id in _server_state["servers"]:
123
+ return {
124
+ "status": "error",
125
+ "content": [{"text": f"❌ Server '{server_id}' is already running"}],
126
+ }
127
+
128
+ if not agent:
129
+ return {
130
+ "status": "error",
131
+ "content": [{"text": "❌ Tool context not available"}],
132
+ }
133
+
134
+ # Get all agent tools
135
+ all_tools = agent.tool_registry.get_all_tools_config()
136
+ if not all_tools:
137
+ return {"status": "error", "content": [{"text": "❌ No tools found in agent"}]}
138
+
139
+ # Filter tools based on tools_filter parameter
140
+ if tools_filter:
141
+ agent_tools = {
142
+ name: spec
143
+ for name, spec in all_tools.items()
144
+ if name in tools_filter and name != "mcp_server"
145
+ }
146
+ if not agent_tools and not expose_agent:
147
+ return {
148
+ "status": "error",
149
+ "content": [
150
+ {
151
+ "text": f"❌ No matching tools found. Available: {list(all_tools.keys())}"
152
+ }
153
+ ],
154
+ }
155
+ else:
156
+ agent_tools = {
157
+ name: spec for name, spec in all_tools.items() if name != "mcp_server"
158
+ }
159
+
160
+ logger.debug(
161
+ f"Creating MCP server with {len(agent_tools)} tools: {list(agent_tools.keys())}"
162
+ )
163
+
164
+ try:
165
+ # Create low-level MCP server
166
+ server = Server(f"devduck-{server_id}")
167
+
168
+ # Create MCP Tool objects from agent tools
169
+ mcp_tools = []
170
+ for tool_name, tool_spec in agent_tools.items():
171
+ description = tool_spec.get("description", f"DevDuck tool: {tool_name}")
172
+ input_schema = {}
173
+
174
+ if "inputSchema" in tool_spec:
175
+ if "json" in tool_spec["inputSchema"]:
176
+ input_schema = tool_spec["inputSchema"]["json"]
177
+ else:
178
+ input_schema = tool_spec["inputSchema"]
179
+
180
+ mcp_tools.append(
181
+ types.Tool(
182
+ name=tool_name,
183
+ description=description,
184
+ inputSchema=input_schema,
185
+ )
186
+ )
187
+
188
+ # Add agent invocation tool if requested
189
+ if expose_agent:
190
+ agent_invoke_tool = types.Tool(
191
+ name="devduck",
192
+ description=(
193
+ "Invoke a FULL DevDuck instance with complete capabilities. "
194
+ "Each invocation creates a fresh DevDuck agent with self-healing, "
195
+ "hot-reload, all tools, knowledge base integration, and system prompt building. "
196
+ "Use this for complex queries requiring reasoning, multi-tool orchestration, "
197
+ "or when you need the complete DevDuck experience via MCP."
198
+ ),
199
+ inputSchema={
200
+ "type": "object",
201
+ "properties": {
202
+ "prompt": {
203
+ "type": "string",
204
+ "description": "The prompt or query to send to DevDuck",
205
+ }
206
+ },
207
+ "required": ["prompt"],
208
+ },
209
+ )
210
+ mcp_tools.append(agent_invoke_tool)
211
+
212
+ logger.debug(
213
+ f"Created {len(mcp_tools)} MCP tools (agent invocation: {expose_agent})"
214
+ )
215
+
216
+ # Capture transport in closure
217
+ _transport = transport
218
+
219
+ # Register list_tools handler
220
+ @server.list_tools()
221
+ async def list_tools() -> list[types.Tool]:
222
+ """Return list of available MCP tools."""
223
+ logger.debug(f"list_tools called, returning {len(mcp_tools)} tools")
224
+ return mcp_tools
225
+
226
+ # Register call_tool handler
227
+ @server.call_tool()
228
+ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
229
+ """Handle tool calls from MCP clients."""
230
+ try:
231
+ logger.debug(f"call_tool: name={name}, arguments={arguments}")
232
+
233
+ # Handle agent invocation tool - create a full DevDuck instance
234
+ if name == "devduck" and expose_agent:
235
+ prompt = arguments.get("prompt")
236
+ if not prompt:
237
+ return [
238
+ types.TextContent(
239
+ type="text",
240
+ text="❌ Error: 'prompt' parameter is required",
241
+ )
242
+ ]
243
+
244
+ logger.debug(f"Invoking devduck with prompt: {prompt[:100]}...")
245
+
246
+ # Create a NEW DevDuck instance for this MCP invocation
247
+ # This gives full DevDuck power: self-healing, hot-reload, all tools, etc.
248
+ try:
249
+ from devduck import DevDuck
250
+
251
+ # Create fresh DevDuck instance (no auto-start to avoid recursion)
252
+ mcp_devduck = DevDuck(auto_start_servers=False)
253
+ mcp_agent = mcp_devduck.agent
254
+
255
+ if not mcp_agent:
256
+ return [
257
+ types.TextContent(
258
+ type="text",
259
+ text="❌ Error: Failed to create DevDuck instance",
260
+ )
261
+ ]
262
+
263
+ # Execute with full DevDuck capabilities
264
+ result = mcp_agent(prompt)
265
+
266
+ except Exception as e:
267
+ logger.error(f"DevDuck creation failed: {e}", exc_info=True)
268
+ return [
269
+ types.TextContent(
270
+ type="text",
271
+ text=f"❌ Error creating DevDuck instance: {str(e)}",
272
+ )
273
+ ]
274
+
275
+ # Extract text response from agent result
276
+ response_text = str(result)
277
+
278
+ logger.debug(
279
+ f"DevDuck invocation complete, response length: {len(response_text)}"
280
+ )
281
+
282
+ return [types.TextContent(type="text", text=response_text)]
283
+
284
+ # Check if tool exists in agent
285
+ if name not in agent_tools:
286
+ return [
287
+ types.TextContent(type="text", text=f"❌ Unknown tool: {name}")
288
+ ]
289
+
290
+ # Call the agent tool
291
+ tool_caller = getattr(agent.tool, name.replace("-", "_"))
292
+
293
+ # For stdio transport, try to pass non_interactive=True if the tool supports it
294
+ if _transport == "stdio":
295
+ try:
296
+ result = tool_caller(**arguments, non_interactive=True)
297
+ except TypeError:
298
+ logger.debug(
299
+ f"Tool '{name}' doesn't support non_interactive parameter"
300
+ )
301
+ result = tool_caller(**arguments)
302
+ else:
303
+ result = tool_caller(**arguments)
304
+
305
+ logger.debug(f"Tool '{name}' execution complete")
306
+
307
+ # Convert result to MCP TextContent format
308
+ mcp_content = []
309
+ if isinstance(result, dict) and "content" in result:
310
+ for item in result.get("content", []):
311
+ if isinstance(item, dict) and "text" in item:
312
+ mcp_content.append(
313
+ types.TextContent(type="text", text=item["text"])
314
+ )
315
+ else:
316
+ mcp_content.append(types.TextContent(type="text", text=str(result)))
317
+
318
+ return (
319
+ mcp_content
320
+ if mcp_content
321
+ else [
322
+ types.TextContent(
323
+ type="text", text="✅ Tool executed successfully"
324
+ )
325
+ ]
326
+ )
327
+
328
+ except Exception as e:
329
+ logger.exception(f"Error calling tool '{name}'")
330
+ return [types.TextContent(type="text", text=f"❌ Error: {str(e)}")]
331
+
332
+ # Record server state
333
+ _server_state["servers"][server_id] = {
334
+ "server": server,
335
+ "transport": transport,
336
+ "port": port,
337
+ "stateless": stateless,
338
+ "tools": list(agent_tools.keys()),
339
+ "start_time": time.time(),
340
+ "status": "starting",
341
+ "expose_agent": expose_agent,
342
+ }
343
+
344
+ if _server_state["default_server"] is None:
345
+ _server_state["default_server"] = server_id
346
+
347
+ # For stdio transport: Run in FOREGROUND
348
+ if transport == "stdio":
349
+ logger.info(
350
+ f"Starting MCP server '{server_id}' in stdio mode (foreground, blocking)"
351
+ )
352
+ _server_state["servers"][server_id]["status"] = "running"
353
+
354
+ import asyncio
355
+ from mcp.server.stdio import stdio_server
356
+
357
+ async def run_stdio() -> None:
358
+ """Run stdio server in foreground."""
359
+ async with stdio_server() as streams:
360
+ await server.run(
361
+ streams[0], streams[1], server.create_initialization_options()
362
+ )
363
+
364
+ asyncio.run(run_stdio())
365
+
366
+ if server_id in _server_state["servers"]:
367
+ del _server_state["servers"][server_id]
368
+
369
+ return {
370
+ "status": "success",
371
+ "content": [{"text": f"✅ MCP server '{server_id}' stopped"}],
372
+ }
373
+
374
+ # For http transport: Run in BACKGROUND
375
+ server_thread = threading.Thread(
376
+ target=_run_mcp_server,
377
+ args=(server, transport, port, stateless, server_id, len(mcp_tools)),
378
+ daemon=True,
379
+ )
380
+
381
+ _server_state["servers"][server_id]["thread"] = server_thread
382
+ server_thread.start()
383
+
384
+ # Give server time to start
385
+ time.sleep(2)
386
+
387
+ # Check status
388
+ if server_id not in _server_state["servers"]:
389
+ return {
390
+ "status": "error",
391
+ "content": [{"text": f"❌ Server '{server_id}' failed to start"}],
392
+ }
393
+
394
+ server_info = _server_state["servers"][server_id]
395
+ if server_info["status"] == "error":
396
+ error_msg = server_info.get("error", "Unknown error")
397
+ return {
398
+ "status": "error",
399
+ "content": [{"text": f"❌ Server '{server_id}' failed: {error_msg}"}],
400
+ }
401
+
402
+ # Update to running
403
+ _server_state["servers"][server_id]["status"] = "running"
404
+
405
+ # Build status message
406
+ tool_list = "\n".join(f" • {tool.name}" for tool in mcp_tools[:10])
407
+ if len(mcp_tools) > 10:
408
+ tool_list += f"\n ... and {len(mcp_tools) - 10} more"
409
+
410
+ if expose_agent:
411
+ tool_list += "\n • devduck (full devduck invocation) ✨"
412
+
413
+ mode_desc = (
414
+ "stateless (multi-node ready)"
415
+ if stateless
416
+ else "stateful (session persistence)"
417
+ )
418
+ message = (
419
+ f"✅ MCP server '{server_id}' started on port {port}\n\n"
420
+ f"📊 Mode: {mode_desc}\n"
421
+ f"🔧 Exposed {len(mcp_tools)} tools:\n"
422
+ f"{tool_list}\n\n"
423
+ f"🔗 Connect at: http://localhost:{port}/mcp"
424
+ )
425
+
426
+ return {"status": "success", "content": [{"text": message}]}
427
+
428
+ except Exception as e:
429
+ logger.exception("Error starting MCP server")
430
+
431
+ if server_id in _server_state["servers"]:
432
+ _server_state["servers"][server_id]["status"] = "error"
433
+ _server_state["servers"][server_id]["error"] = str(e)
434
+
435
+ return {
436
+ "status": "error",
437
+ "content": [{"text": f"❌ Failed to start MCP server: {str(e)}"}],
438
+ }
439
+
440
+
441
+ def _run_mcp_server(
442
+ server: "Server",
443
+ transport: str,
444
+ port: int,
445
+ stateless: bool,
446
+ server_id: str,
447
+ tool_count: int,
448
+ ) -> None:
449
+ """Run MCP server in background thread."""
450
+ try:
451
+ logger.debug(
452
+ f"Starting MCP server: server_id={server_id}, transport={transport}, port={port}, stateless={stateless}"
453
+ )
454
+
455
+ if transport == "http":
456
+ session_manager = StreamableHTTPSessionManager(
457
+ app=server,
458
+ event_store=None,
459
+ json_response=False,
460
+ stateless=stateless,
461
+ )
462
+
463
+ async def handle_streamable_http(
464
+ scope: Scope, receive: Receive, send: Send
465
+ ) -> None:
466
+ await session_manager.handle_request(scope, receive, send)
467
+
468
+ @contextlib.asynccontextmanager
469
+ async def lifespan(app: Starlette) -> AsyncIterator[None]:
470
+ async with session_manager.run():
471
+ logger.info(
472
+ f"MCP server '{server_id}' running (stateless={stateless})"
473
+ )
474
+ try:
475
+ yield
476
+ finally:
477
+ logger.info(f"MCP server '{server_id}' shutting down...")
478
+
479
+ starlette_app = Starlette(
480
+ debug=True,
481
+ routes=[
482
+ Mount("/mcp", app=handle_streamable_http),
483
+ ],
484
+ lifespan=lifespan,
485
+ )
486
+
487
+ starlette_app = CORSMiddleware(
488
+ starlette_app,
489
+ allow_origins=["*"],
490
+ allow_methods=["GET", "POST", "DELETE"],
491
+ expose_headers=["Mcp-Session-Id"],
492
+ )
493
+
494
+ logger.debug(f"Starting Uvicorn server on 0.0.0.0:{port}")
495
+ uvicorn.run(starlette_app, host="0.0.0.0", port=port, log_level="info")
496
+ else:
497
+ logger.error(f"Unsupported transport: {transport}")
498
+
499
+ except Exception as e:
500
+ logger.exception("Error in _run_mcp_server")
501
+
502
+ if server_id in _server_state["servers"]:
503
+ _server_state["servers"][server_id]["status"] = "error"
504
+ _server_state["servers"][server_id]["error"] = str(e)
505
+
506
+
507
+ def _stop_mcp_server(server_id: str) -> dict[str, Any]:
508
+ """Stop a running MCP server."""
509
+ if server_id not in _server_state["servers"]:
510
+ return {
511
+ "status": "error",
512
+ "content": [{"text": f"❌ Server '{server_id}' is not running"}],
513
+ }
514
+
515
+ server_info = _server_state["servers"][server_id]
516
+ server_info["status"] = "stopping"
517
+
518
+ del _server_state["servers"][server_id]
519
+
520
+ if _server_state["default_server"] == server_id:
521
+ _server_state["default_server"] = (
522
+ next(iter(_server_state["servers"])) if _server_state["servers"] else None
523
+ )
524
+
525
+ return {
526
+ "status": "success",
527
+ "content": [{"text": f"✅ MCP server '{server_id}' stopped"}],
528
+ }
529
+
530
+
531
+ def _get_mcp_status() -> dict[str, Any]:
532
+ """Get status of all MCP servers."""
533
+ if not _server_state["servers"]:
534
+ return {"status": "success", "content": [{"text": "ℹ️ No MCP servers running"}]}
535
+
536
+ lines = ["📡 **MCP Server Status**\n"]
537
+
538
+ for server_id, server_info in _server_state["servers"].items():
539
+ uptime = time.time() - server_info["start_time"]
540
+ uptime_str = f"{int(uptime // 60)}m {int(uptime % 60)}s"
541
+
542
+ default_marker = (
543
+ " (default)" if server_id == _server_state["default_server"] else ""
544
+ )
545
+ status_emoji = {
546
+ "running": "✅",
547
+ "starting": "🔄",
548
+ "stopping": "⏸️",
549
+ "error": "❌",
550
+ }.get(server_info["status"], "❓")
551
+
552
+ lines.append(f"\n**{server_id}{default_marker}**")
553
+ lines.append(f" • Status: {status_emoji} {server_info['status']}")
554
+ lines.append(f" • Transport: {server_info['transport']}")
555
+
556
+ if server_info["transport"] == "http":
557
+ lines.append(f" • Port: {server_info['port']}")
558
+ lines.append(f" • Connect: http://localhost:{server_info['port']}/mcp")
559
+ mode_type = (
560
+ "stateless (multi-node)"
561
+ if server_info.get("stateless", False)
562
+ else "stateful (single-node)"
563
+ )
564
+ lines.append(f" • Type: {mode_type}")
565
+
566
+ lines.append(f" • Uptime: {uptime_str}")
567
+ lines.append(f" • Tools: {len(server_info['tools'])} exposed")
568
+
569
+ if server_info.get("expose_agent"):
570
+ lines.append(f" • Agent Invocation: ✅ Enabled")
571
+
572
+ if server_info["status"] == "error" and "error" in server_info:
573
+ lines.append(f" • Error: {server_info['error']}")
574
+
575
+ return {"status": "success", "content": [{"text": "\n".join(lines)}]}
576
+
577
+
578
+ def _list_mcp_servers() -> dict[str, Any]:
579
+ """List running MCP servers."""
580
+ if not _server_state["servers"]:
581
+ return {"status": "success", "content": [{"text": "ℹ️ No MCP servers running"}]}
582
+
583
+ lines = ["📋 **Running MCP Servers**\n"]
584
+
585
+ for server_id, server_info in _server_state["servers"].items():
586
+ default_marker = (
587
+ " (default)" if server_id == _server_state["default_server"] else ""
588
+ )
589
+ mode_info = (
590
+ f"port {server_info['port']}"
591
+ if server_info["transport"] == "http"
592
+ else "stdio"
593
+ )
594
+
595
+ lines.append(
596
+ f"• {server_id}{default_marker}: {server_info['status']}, "
597
+ f"{server_info['transport']} ({mode_info})"
598
+ )
599
+
600
+ return {"status": "success", "content": [{"text": "\n".join(lines)}]}