Skip to content

Commit 5444754

Browse files
committed
feat: add list_organization_skills tool to fetch skill taxonomy
- Implements fetching all skills from /v1/skill endpoint - Optimizes context by grouping skills by category - Prevents N+1 LLM calls when user asks 'what skills do we have'
1 parent e33515e commit 5444754

File tree

2 files changed

+73
-15
lines changed

2 files changed

+73
-15
lines changed

src/agileday_server.py

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ async def list_tools() -> list[Tool]:
5757
},
5858
"required": ["name_query"]
5959
}
60+
),
61+
# NEW TOOL ADDED HERE
62+
Tool(
63+
name="list_organization_skills",
64+
description="Retrieves a full list of all available skills/competences defined in the organization.",
65+
inputSchema={
66+
"type": "object",
67+
"properties": {}, # No arguments needed
68+
}
6069
)
6170
]
6271

@@ -66,6 +75,11 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent | ImageConte
6675
return [TextContent(type="text", text=find_experts_logic(arguments["skill_name"]))]
6776
if name == "get_employee_competence_profile":
6877
return [TextContent(type="text", text=profile_logic(arguments["name_query"]))]
78+
79+
# NEW LOGIC HANDLER
80+
if name == "list_organization_skills":
81+
return [TextContent(type="text", text=list_skills_logic())]
82+
6983
raise ValueError(f"Unknown tool: {name}")
7084

7185
# --- Helper Functions ---
@@ -124,42 +138,66 @@ def profile_logic(name_query: str) -> str:
124138

125139
return "\n\n".join(results) if results else f"No employee found for '{name_query}'."
126140

141+
# NEW LOGIC FUNCTION
142+
def list_skills_logic() -> str:
143+
try:
144+
url = f"{BASE_URL}/v1/skill"
145+
response = requests.get(url, headers=get_headers(), timeout=15)
146+
response.raise_for_status()
147+
skills = response.json()
148+
except Exception as e:
149+
return f"Error fetching skills: {str(e)}"
150+
151+
if not skills:
152+
return "No skills defined in the library."
153+
154+
# Group skills by category for better readability
155+
categories = {}
156+
for s in skills:
157+
# Default to "Other" if category is missing/null
158+
cat = s.get('category') or "Uncategorized"
159+
if cat not in categories:
160+
categories[cat] = []
161+
categories[cat].append(s.get('name', 'Unnamed Skill'))
162+
163+
output = ["**Organization Skill Library**\n"]
164+
165+
# Sort categories alphabetically
166+
for cat in sorted(categories.keys()):
167+
skill_names = sorted(categories[cat])
168+
# Format: "### Category Name \n - Skill 1, Skill 2..."
169+
output.append(f"### {cat}")
170+
output.append(", ".join(skill_names))
171+
output.append("") # Empty line for spacing
172+
173+
return "\n".join(output)
174+
127175
# --- 3. Transport Layer (Raw ASGI) ---
128176

129177
sse_transport = SseServerTransport("/mcp")
130178

131179
class MCPHandler:
132-
"""
133-
A Raw ASGI Handler.
134-
This class bypasses Starlette's Request/Response lifecycle entirely.
135-
It passes the raw 'send' channel to the MCP SDK, allowing the SDK
136-
to write the response headers and body directly without conflict.
137-
"""
138180
async def __call__(self, scope, receive, send):
139181
if scope["type"] != "http":
140182
return
141183

142184
if scope["method"] == "POST":
143-
# The SDK handles reading the body and sending the '202 Accepted' response.
144185
await sse_transport.handle_post_message(scope, receive, send)
145186

146187
elif scope["method"] == "GET":
147-
# The SDK handles the SSE handshake and streaming.
148188
async with sse_transport.connect_sse(scope, receive, send) as streams:
149189
await server.run(streams[0], streams[1], server.create_initialization_options())
150190

151191
else:
152-
# Manual 405 Method Not Allowed
153192
response = Response("Method Not Allowed", status_code=405)
154193
await response(scope, receive, send)
155194

156195
mcp_asgi_app = MCPHandler()
157196

158197
routes = [
159-
# Starlette detects `mcp_asgi_app` is an ASGI app (has __call__) and treats it as a raw endpoint.
160198
Route("/mcp", endpoint=mcp_asgi_app, methods=["GET", "POST"]),
161-
Route("/sse", endpoint=mcp_asgi_app, methods=["GET"]), # Deprecated alias
162-
Route("/messages", endpoint=mcp_asgi_app, methods=["POST"]), # Alias
199+
Route("/sse", endpoint=mcp_asgi_app, methods=["GET"]),
200+
Route("/messages", endpoint=mcp_asgi_app, methods=["POST"]),
163201
Route("/health", endpoint=lambda r: Response("OK"))
164202
]
165203

tests/test_agileday_server.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
# Add src to path so we can import the server
1111
sys.path.append(os.path.join(os.path.dirname(__file__), '../src'))
1212

13-
# IMPORTS UPDATED: We now import the specific logic functions
14-
from agileday_server import find_experts_logic, profile_logic, BASE_URL
13+
# Add list_skills_logic to imports
14+
from agileday_server import find_experts_logic, profile_logic, list_skills_logic, BASE_URL
1515

1616
# --- Fixtures ---
1717

@@ -111,4 +111,24 @@ def test_api_failure_handling(mock_api_response):
111111

112112
# Note: In the new implementation we return "Error: ..."
113113
assert "Error" in result
114-
assert "API Connection Failed" in result
114+
assert "API Connection Failed" in result
115+
116+
def test_list_skills_grouping(mock_api_response):
117+
"""Test that skills are fetched and grouped by category."""
118+
mock_data = [
119+
{"name": "Python", "category": "Backend"},
120+
{"name": "Java", "category": "Backend"},
121+
{"name": "React", "category": "Frontend"},
122+
{"name": "Figma", "category": None} # Should be 'Uncategorized'
123+
]
124+
mock_api_response.return_value.json.return_value = mock_data
125+
126+
result = list_skills_logic()
127+
128+
# Verify Output Formatting
129+
assert "### Backend" in result
130+
assert "Python, Java" in result or "Java, Python" in result
131+
assert "### Frontend" in result
132+
assert "React" in result
133+
assert "### Uncategorized" in result
134+
assert "Figma" in result

0 commit comments

Comments
 (0)