Skip to content

Commit c92292b

Browse files
authored
Merge pull request #15 from geekbot-com/feature/post-report
Add post_report tool
2 parents edb6c73 + 6e75f59 commit c92292b

File tree

9 files changed

+226
-3
lines changed

9 files changed

+226
-3
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,21 @@ Once configured, your LLM client application will have access to the following t
146146
- `created_at`: Timestamp when the report was submitted.
147147
- `content`: The actual answers/content of the report.
148148
149+
- `post_report`
150+
151+
**Purpose:** Posts a report to Geekbot.
152+
153+
**Example Prompt:** "Hey, can you post the report for the Daily Standup standup?"
154+
155+
**Data Fields Returned:**
156+
157+
- `id`: Unique report identifier.
158+
- `reporter_name`: Name of the user who submitted the report.
159+
- `reporter_id`: ID of the user who submitted the report.
160+
- `standup_id`: ID of the standup the report belongs to.
161+
- `created_at`: Timestamp when the report was submitted.
162+
- `content`: The actual answers/content of the report.
163+
149164
- `list_members`
150165
151166
**Purpose:** Lists all team members you share standups with in your Geekbot workspace.

geekbot_mcp/gb_api.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,21 @@ async def get_reports(
5757
response.raise_for_status()
5858
return response.json()
5959

60+
async def post_report(
61+
self,
62+
standup_id: int,
63+
answers: dict[int, dict[str, str]],
64+
) -> dict:
65+
"""Post a report"""
66+
endpoint = f"{self.base_url}/reports/"
67+
response = await self._client.post(
68+
endpoint,
69+
headers=self.headers,
70+
json={"standup_id": standup_id, "answers": answers},
71+
)
72+
response.raise_for_status()
73+
return response.json()
74+
6075
async def get_poll_results(
6176
self, poll_id: int, after: str | None = None, before: str | None = None
6277
) -> dict:

geekbot_mcp/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,18 @@ def report_from_json_response(r_res: dict) -> Report:
188188
)
189189

190190

191+
def posted_report_from_json_response(r_res: dict) -> Report:
192+
return Report(
193+
id=r_res["id"],
194+
standup_id=r_res["standup_id"],
195+
created_at=datetime.fromtimestamp(r_res["done_at"]).strftime(
196+
"%Y-%m-%d %H:%M:%S"
197+
),
198+
reporter=reporter_from_json_response(r_res["member"]),
199+
content=content_from_json_response(r_res["answers"]),
200+
)
201+
202+
191203
def poll_choice_result_from_json_response(c_res: dict) -> PollChoiceResult:
192204
return PollChoiceResult(
193205
text=c_res["text"],

geekbot_mcp/tools/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
from geekbot_mcp.tools.list_members import handle_list_members, list_members
1010
from geekbot_mcp.tools.list_polls import handle_list_polls, list_polls
1111
from geekbot_mcp.tools.list_standups import handle_list_standups, list_standups
12+
from geekbot_mcp.tools.post_report import handle_post_report, post_report
1213

1314

1415
def list_tools() -> list[types.Tool]:
1516
return [
1617
list_members,
1718
list_standups,
1819
fetch_reports,
20+
post_report,
1921
list_polls,
2022
fetch_poll_results,
2123
]
@@ -33,6 +35,8 @@ async def run_tool(
3335
return await handle_list_standups(gb_client)
3436
case "fetch_reports":
3537
return await handle_fetch_reports(gb_client, **arguments)
38+
case "post_report":
39+
return await handle_post_report(gb_client, **arguments)
3640
case "list_polls":
3741
return await handle_list_polls(gb_client)
3842
case "fetch_poll_results":

geekbot_mcp/tools/post_report.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import html
2+
3+
from mcp import types
4+
5+
from geekbot_mcp.gb_api import GeekbotClient
6+
from geekbot_mcp.models import posted_report_from_json_response
7+
8+
post_report = types.Tool(
9+
name="post_report",
10+
description="Posts a report to Geekbot. Use this tool to post a report to Geekbot using the context of the conversation. This tool is usually used after the `list_standups` tool to get the standup id and the question ids. If the context of the conversation lacks sufficient information to answer the questions of the standup, the assistant will ask for the missing information. The report should be beautifully formatted. ALWAYS type formatted reporte in the conversation for preview purposes before calling this tool.",
11+
inputSchema={
12+
"type": "object",
13+
"properties": {
14+
"standup_id": {
15+
"type": "integer",
16+
"description": "ID of the specific standup to post the report to.",
17+
},
18+
"answers": {
19+
"type": "object",
20+
"description": "An object where keys are the string representation of question IDs and values are objects containing the answer text. All questions of the standup must be included in the object.",
21+
"additionalProperties": {
22+
"type": "object",
23+
"properties": {
24+
"text": {"type": "string"},
25+
},
26+
"required": ["text"],
27+
},
28+
},
29+
},
30+
"required": ["standup_id", "answers"],
31+
},
32+
)
33+
34+
35+
def parse_answer_text(answer_text: any) -> str:
36+
"""Parse the answer text to ensure it's a string and escape HTML characters."""
37+
if not isinstance(answer_text, str):
38+
answer_text = str(answer_text)
39+
return html.unescape(answer_text)
40+
41+
42+
async def handle_post_report(
43+
gb_client: GeekbotClient,
44+
standup_id: int,
45+
answers: dict[int, dict[str, str]],
46+
) -> list[types.TextContent]:
47+
"""Post a report to Geekbot
48+
49+
Args:
50+
standup_id: int,
51+
answers: dict[int, dict[str, str]],
52+
Returns:
53+
str: Properly formatted JSON string of reports list
54+
"""
55+
processed_answers_for_api = {}
56+
for question_id, answer_obj in answers.items():
57+
processed_answers_for_api[question_id] = {
58+
"text": parse_answer_text(answer_obj["text"])
59+
}
60+
report = await gb_client.post_report(
61+
standup_id=standup_id,
62+
answers=processed_answers_for_api,
63+
)
64+
parsed_report = posted_report_from_json_response(report)
65+
return [
66+
types.TextContent(
67+
type="text",
68+
text=f"Report posted successfully: {parsed_report.model_dump()}",
69+
)
70+
]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "geekbot-mcp"
7-
version = "0.2.1"
7+
version = "0.3.0"
88
description = "Model Context Protocol (MCP) server integrating Geekbot data and tools to Claude AI"
99
readme = "README.md"
1010
requires-python = ">=3.10"

tests/test_models.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
PollQuestionResult,
88
PollQuestionResults,
99
PollResults,
10+
Report,
1011
User,
1112
poll_results_from_json_response,
13+
posted_report_from_json_response,
1214
)
1315

1416
SAMPLE_POLL_RESULT_JSON = """
@@ -206,7 +208,7 @@
206208
"email": "[email protected]",
207209
"username": "jd",
208210
"realname": "John Doe",
209-
"profile_img": "https://avatars.slack-edge.com/2018-07-17/401189377223_47ad4aede92c871ad992_48.jpg",
211+
"profile_img": "https://avatars.slack-edge.com/jd-avatar.png",
210212
},
211213
],
212214
"recurrence": {
@@ -335,6 +337,60 @@
335337
]
336338
"""
337339

340+
POSTED_REPORT_JSON = """
341+
{
342+
"id": 27391,
343+
"slack_ts": null,
344+
"standup_id": 193911,
345+
"timestamp": 1746521708,
346+
"started_at": 1746521708,
347+
"done_at": 1746521709,
348+
"broadcasted_at": null,
349+
"channel": "--testing",
350+
"member": {
351+
"id": "UBTDMUC34W",
352+
"role": "member",
353+
"username": "jd",
354+
"realname": "John Doe",
355+
"profileImg": "https://avatars.slack-edge.com/jd-avatar.png"
356+
},
357+
"answers": [
358+
{
359+
"id": 179012748,
360+
"answer": "test",
361+
"question": "How do you feel today?",
362+
"question_id": 3143977,
363+
"color": "EEEEEE",
364+
"images": []
365+
},
366+
{
367+
"id": 179011249,
368+
"answer": "test",
369+
"question": "What have you done since {last_report_date}?",
370+
"question_id": 6143980,
371+
"color": "CEF1F3",
372+
"images": []
373+
},
374+
{
375+
"id": 179018750,
376+
"answer": "test",
377+
"question": "What will you do today?",
378+
"question_id": 6143928,
379+
"color": "D299EB",
380+
"images": []
381+
},
382+
{
383+
"id": 179018751,
384+
"answer": "test",
385+
"question": "Anything blocking your progress?",
386+
"question_id": 6143979,
387+
"color": "FBDADD",
388+
"images": []
389+
}
390+
]
391+
}
392+
"""
393+
338394

339395
@pytest.fixture
340396
def sample_poll_result_data() -> dict:
@@ -354,6 +410,12 @@ def polls_list() -> list[dict]:
354410
return POLLS_LIST
355411

356412

413+
@pytest.fixture
414+
def posted_report_data() -> dict:
415+
"""Provides the posted report JSON data as a dictionary."""
416+
return json.loads(POSTED_REPORT_JSON)
417+
418+
357419
def test_poll_results_parsing(sample_poll_result_data: dict):
358420
"""Tests parsing of sample poll results JSON into a PollResults object."""
359421
parsed_result = poll_results_from_json_response(sample_poll_result_data)
@@ -422,3 +484,15 @@ def test_poll_results_parsing_multiple_instances(
422484
assert choice1.votes == 0
423485
assert choice1.percentage == 0.0
424486
assert len(choice1.users) == 0
487+
488+
489+
def test_posted_report_parsing(posted_report_data: dict):
490+
"""Tests parsing of posted report JSON into a PostedReport object."""
491+
parsed_result = posted_report_from_json_response(posted_report_data)
492+
493+
assert isinstance(parsed_result, Report)
494+
assert parsed_result.id == 27391
495+
assert parsed_result.standup_id == 193911
496+
assert parsed_result.reporter.id == "UBTDMUC34W"
497+
assert parsed_result.reporter.name == "John Doe"
498+
assert parsed_result.reporter.username == "jd"

tests/test_tools.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from geekbot_mcp.tools.post_report import parse_answer_text
2+
3+
4+
def test_parse_answer_text():
5+
assert (
6+
parse_answer_text("This is a test. It's great!")
7+
== "This is a test. It's great!"
8+
)
9+
10+
assert parse_answer_text(123) == "123"
11+
12+
assert (
13+
parse_answer_text("<div>Hello & World</div>")
14+
== "<div>Hello &amp; World</div>"
15+
)
16+
17+
assert (
18+
parse_answer_text(
19+
"&lt;p&gt;Less than &amp;lt; and greater than &amp;gt;&lt;/p&gt;"
20+
)
21+
== "<p>Less than &lt; and greater than &gt;</p>"
22+
)
23+
assert (
24+
parse_answer_text("'Single quote &amp;apos; and double quote &amp;quot;'")
25+
== "'Single quote &apos; and double quote &quot;'"
26+
)
27+
28+
assert (
29+
parse_answer_text(
30+
"The main challenge we&#039;re facing involves Slack API limitations, which are creating some constraints on what we can implement. However, I&#039;m confident we can find creative workarounds for these issues. Nothing we can&#039;t overcome with a bit of innovative thinking!"
31+
)
32+
== "The main challenge we're facing involves Slack API limitations, which are creating some constraints on what we can implement. However, I'm confident we can find creative workarounds for these issues. Nothing we can't overcome with a bit of innovative thinking!"
33+
)

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)