55import json
66import logging
77import os
8+ import zipfile
89import pathlib
910import time
1011import uuid
5354SHARED_DIR .mkdir (exist_ok = True )
5455KERNEL_ID_FILE_PATH = SHARED_DIR / "python_kernel_id.txt"
5556
57+ # Skills directory configuration
58+ SKILLS_DIR = SHARED_DIR / "skills"
59+ PUBLIC_SKILLS_DIR = SKILLS_DIR / "public"
60+ USER_SKILLS_DIR = SKILLS_DIR / "user"
61+
5662def resolve_with_system_dns (hostname ):
5763 try :
5864 return socket .gethostbyname (hostname )
@@ -530,5 +536,149 @@ async def navigate_and_get_all_visible_text(url: str) -> str:
530536 return f"Error: Failed to retrieve all visible text: { str (e )} "
531537
532538
539+ # --- SKILLS MANAGEMENT TOOLS ---
540+
541+
542+ async def _parse_skill_frontmatter (skill_md_path ):
543+ try :
544+ async with aiofiles .open (skill_md_path , mode = 'r' ) as f :
545+ content = await f .read ()
546+ frontmatter = []
547+ in_frontmatter = False
548+ for line in content .splitlines ():
549+ if line .strip () == '---' :
550+ if in_frontmatter :
551+ break
552+ else :
553+ in_frontmatter = True
554+ continue
555+ if in_frontmatter :
556+ frontmatter .append (line )
557+
558+ metadata = {}
559+ for line in frontmatter :
560+ if ':' in line :
561+ key , value = line .split (':' , 1 )
562+ metadata [key .strip ()] = value .strip ()
563+ return metadata
564+ except Exception :
565+ return {}
566+
567+ @mcp .tool ()
568+ async def list_skills () -> str :
569+ """
570+ Lists all available skills in the CodeRunner container.
571+
572+ Returns a list of available skills organized by category (public/user).
573+ Public skills are built into the container, while user skills are added by users.
574+
575+ Returns:
576+ JSON string with skill names organized by category.
577+ """
578+ try :
579+ # Unzip any user-provided skills
580+ if USER_SKILLS_DIR .exists ():
581+ for item in USER_SKILLS_DIR .iterdir ():
582+ if item .is_file () and item .suffix == '.zip' :
583+ with zipfile .ZipFile (item , 'r' ) as zip_ref :
584+ zip_ref .extractall (USER_SKILLS_DIR )
585+ os .remove (item )
586+
587+ skills = {
588+ "public" : [],
589+ "user" : []
590+ }
591+
592+ # Helper to process a skills directory
593+ async def process_skill_dir (directory , category ):
594+ if directory .exists ():
595+ for skill_dir in directory .iterdir ():
596+ if skill_dir .is_dir ():
597+ skill_md_path = skill_dir / "SKILL.md"
598+ if skill_md_path .exists ():
599+ metadata = await _parse_skill_frontmatter (skill_md_path )
600+ skills [category ].append ({
601+ "name" : metadata .get ("name" , skill_dir .name ),
602+ "description" : metadata .get ("description" , "No description available." )
603+ })
604+
605+ await process_skill_dir (PUBLIC_SKILLS_DIR , "public" )
606+ await process_skill_dir (USER_SKILLS_DIR , "user" )
607+
608+ # Sort for consistent output
609+ skills ["public" ].sort (key = lambda x : x ['name' ])
610+ skills ["user" ].sort (key = lambda x : x ['name' ])
611+
612+ result = f"Available Skills:\n \n "
613+ result += f"Public Skills ({ len (skills ['public' ])} ):\n "
614+ if skills ["public" ]:
615+ for skill in skills ["public" ]:
616+ result += f" - { skill ['name' ]} : { skill ['description' ]} \n "
617+ else :
618+ result += " (none)\n "
619+
620+ result += f"\n User Skills ({ len (skills ['user' ])} ):\n "
621+ if skills ["user" ]:
622+ for skill in skills ["user" ]:
623+ result += f" - { skill ['name' ]} : { skill ['description' ]} \n "
624+ else :
625+ result += " (none)\n "
626+
627+ result += f"\n Use get_skill_info(skill_name) to read documentation for a specific skill."
628+
629+ return result
630+
631+ except Exception as e :
632+ logger .error (f"Failed to list skills: { e } " )
633+ return f"Error: Failed to list skills: { str (e )} "
634+
635+
636+ @mcp .tool ()
637+ async def get_skill_info (skill_name : str ) -> str :
638+ """
639+ Retrieves the documentation (SKILL.md) for a specific skill.
640+
641+ Args:
642+ skill_name: The name of the skill (e.g., 'pdf-text-replace', 'image-crop-rotate')
643+
644+ Returns:
645+ The content of the skill's SKILL.md file with usage instructions and examples.
646+ """
647+ try :
648+ # Check public skills first
649+ public_skill_path = PUBLIC_SKILLS_DIR / skill_name / "SKILL.md"
650+ user_skill_path = USER_SKILLS_DIR / skill_name / "SKILL.md"
651+
652+ skill_path = None
653+ skill_type = None
654+
655+ if public_skill_path .exists ():
656+ skill_path = public_skill_path
657+ skill_type = "public"
658+ elif user_skill_path .exists ():
659+ skill_path = user_skill_path
660+ skill_type = "user"
661+ else :
662+ return f"Error: Skill '{ skill_name } ' not found. Use list_skills() to see available skills."
663+
664+ # Read the SKILL.md content
665+ async with aiofiles .open (skill_path , mode = 'r' ) as f :
666+ content = await f .read ()
667+
668+ # Replace all occurrences of /mnt/user-data with /app/uploads
669+ content = content .replace ('/mnt/user-data' , '/app/uploads' )
670+
671+ # Add header with skill type
672+ header = f"Skill: { skill_name } ({ skill_type } )\n "
673+ header += f"Location: /app/uploads/skills/{ skill_type } /{ skill_name } /\n "
674+ header += "=" * 80 + "\n \n "
675+
676+ return header + content
677+
678+ except Exception as e :
679+ logger .error (f"Failed to get skill info for '{ skill_name } ': { e } " )
680+ return f"Error: Failed to get skill info: { str (e )} "
681+
682+
533683# Use the streamable_http_app as it's the modern standard
534684app = mcp .streamable_http_app ()
0 commit comments