From f1ae6c636c6fed359577a5170a76c2e11a5d25ca Mon Sep 17 00:00:00 2001 From: Jeremy Eder Date: Fri, 20 Mar 2026 17:04:24 -0400 Subject: [PATCH] feat: add scrum_team_board table, MCP tool, basic auth, faster rollouts - Load "Scrum Team Boards" xlsx sheet into new scrum_team_board table - Add list_scrum_team_boards MCP tool with optional org filter - Add basic auth middleware (GPS_AUTH_USER/GPS_AUTH_PASS env vars) with query param token fallback for clients that don't send headers - Speed up rollouts: maxUnavailable=1, faster readiness probe - Add docs/QUICKSTART.md with setup and example output Co-Authored-By: Claude Opus 4.6 (1M context) --- data/schema.sql | 18 +++++++ deploy/k8s/base/deployment.yaml | 9 +++- docs/QUICKSTART.md | 63 +++++++++++++++++++++++ mcp_server.py | 88 +++++++++++++++++++++++++++++++-- scripts/build_db.py | 87 ++++++++++++++++++++++++++++++++ scripts/test.sh | 2 +- 6 files changed, 260 insertions(+), 7 deletions(-) create mode 100644 docs/QUICKSTART.md diff --git a/data/schema.sql b/data/schema.sql index 83ec38b..83b3140 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -146,6 +146,24 @@ CREATE TABLE scrum_team ( team_id INTEGER PRIMARY KEY, team_name TEXT UNIQUE NOT NULL, pm TEXT, eng_lead TEXT ); +CREATE TABLE scrum_team_board ( + id INTEGER PRIMARY KEY, + organization TEXT, + scrum_team_name TEXT NOT NULL, + jira_board_url TEXT, + pm TEXT, + agilist REAL, + architects REAL, + bff REAL, + backend_engineer REAL, + devops REAL, + manager REAL, + operations_manager REAL, + qe REAL, + staff_engineers REAL, + ui REAL, + total_staff REAL +); CREATE TABLE specialty ( specialty_id INTEGER PRIMARY KEY, specialty_name TEXT UNIQUE NOT NULL ); diff --git a/deploy/k8s/base/deployment.yaml b/deploy/k8s/base/deployment.yaml index d10cc62..121f436 100644 --- a/deploy/k8s/base/deployment.yaml +++ b/deploy/k8s/base/deployment.yaml @@ -8,6 +8,11 @@ metadata: app.kubernetes.io/part-of: gps spec: replicas: 1 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 selector: matchLabels: app: gps-mcp-server @@ -35,8 +40,8 @@ spec: httpGet: path: /health port: 8000 - initialDelaySeconds: 5 - periodSeconds: 10 + initialDelaySeconds: 2 + periodSeconds: 5 resources: requests: memory: "256Mi" diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 0000000..31972e0 --- /dev/null +++ b/docs/QUICKSTART.md @@ -0,0 +1,63 @@ +# GPS MCP Server — Quick Start + +GPS gives Claude Code instant access to org and engineering data (scrum teams, staffing, issues, features, releases). + +## Setup + +```bash +claude mcp add gps-remote "https://gps-mcp-server-gps-mcp-server.apps.rosa.vteam-stage.7fpc.p3.openshiftapps.com/mcp?token=cmVkaGF0OnJlZGhhdCEhMTIz" -s user -t http +``` + +Then restart Claude Code. + +## Verify + +```bash +claude mcp list | grep gps-remote +# Should show: ✓ Connected +``` + +## Example Queries + +### "Which scrum teams have the most staff?" + +``` +Top 5 scrum teams by total staff: + + AIPCC PyTorch 31.0 + Inf Engineering Runtimes 27.0 + AI Platform General 26.0 + AIPCC Accelerator Enablement 22.0 + watsonx Team AutoRAG/AutoML 22.0 +``` + +### "Show me the AI Platform scrum team boards" + +``` +AI Platform scrum team boards: + + General Staff: 26.0 PM: Jeff DeMoss, Myriam Fentanes Gutierrez, Christoph Görn + Trusty-AI Staff: 20.0 PM: Adel Zaalouk, William Caban + Model Serving Staff: 18.0 PM: Adam Bellusci, Naina Singh, Jonathan Zarecki + Inference Extensions Staff: 13.0 PM: Adam Bellusci, Naina Singh + TestOps Staff: 11.5 PM: N/A + AI Hub Staff: 10.5 PM: Adam Bellusci, Peter Double, Jenny Yi + DevOps & InfraOps Staff: 9.5 PM: N/A + Heimdall Staff: 7.5 PM: Adam Bellusci + Kubeflow Training Staff: 7.0 PM: Christoph Görn + ...and 21 more teams +``` + +### "What's the staffing breakdown for the PyTorch team?" + +``` +PyTorch (AIPCC) — 31.0 total staff + + Backend Engineer: 18.0 + QE: 6.0 + Manager: 4.0 + Staff Engineers: 2.0 + Agilist: 1.0 + PM: Erwan Gallen + Board: https://redhat.atlassian.net/jira/software/c/projects/AIPCC/boards/3735 +``` diff --git a/mcp_server.py b/mcp_server.py index 57ed6dc..88a92d6 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -41,10 +41,10 @@ instructions=( "GPS is a read-only caching tier for org and engineering data. " "Query people, teams, issues, features, release schedules, " - "component mappings, and governance documents with sub-ms latency. " - "Read gps://schema first to understand available tables. " + "component mappings, scrum team boards, and governance documents with sub-ms latency. " + "Use list_scrum_team_boards for scrum team staffing data (FTE counts by role). " "Use list_documents/get_document/get_document_section for governance docs. " - "No auth required — all data is read-only." + "All data is read-only." ), ) @@ -306,6 +306,48 @@ def list_team_members(team_name: str) -> str: return json.dumps({"teams": results}, default=str) +@mcp.tool( + annotations={"readOnlyHint": True, "openWorldHint": False}, +) +def list_scrum_team_boards(organization: str | None = None) -> str: + """List scrum team boards with staffing breakdown by role. + + Optionally filter by organization (fuzzy match). Returns team name, + Jira board URL, PM, and non-zero FTE counts per role. + """ + conn = _get_conn() + query = ( + "SELECT organization, scrum_team_name, jira_board_url, pm, " + "agilist, architects, bff, backend_engineer, devops, manager, " + "operations_manager, qe, staff_engineers, ui, total_staff " + "FROM scrum_team_board" + ) + if organization: + query += " WHERE organization LIKE ?" + rows = conn.execute(query + " ORDER BY total_staff DESC", (f"%{organization}%",)).fetchall() + else: + rows = conn.execute(query + " ORDER BY total_staff DESC").fetchall() + role_cols = [ + "agilist", + "architects", + "bff", + "backend_engineer", + "devops", + "manager", + "operations_manager", + "qe", + "staff_engineers", + "ui", + ] + teams = [] + for row in rows: + d = dict(row) + roles = {k: d.pop(k) for k in role_cols if d.get(k)} + d["roles"] = roles + teams.append(d) + return json.dumps({"teams": teams, "count": len(teams)}, default=str) + + @mcp.tool( annotations={"readOnlyHint": True, "openWorldHint": False}, ) @@ -687,6 +729,40 @@ def _configure_http(port: int = 8000) -> None: ) +def _wrap_basic_auth(app): + """Wrap a Starlette app with basic auth if GPS_AUTH_USER/GPS_AUTH_PASS are set.""" + import base64 + import secrets + + from starlette.middleware.base import BaseHTTPMiddleware + from starlette.responses import Response + + auth_user = os.environ.get("GPS_AUTH_USER", "") + auth_pass = os.environ.get("GPS_AUTH_PASS", "") + if not (auth_user and auth_pass): + return app + + expected = base64.b64encode(f"{auth_user}:{auth_pass}".encode()).decode() + + class BasicAuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + if request.url.path != "/mcp": + return await call_next(request) + # Check Authorization header + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Basic "): + if secrets.compare_digest(auth_header[6:], expected): + return await call_next(request) + # Check query param fallback (for clients that don't support headers) + token = request.query_params.get("token", "") + if token and secrets.compare_digest(token, expected): + return await call_next(request) + return Response("Unauthorized", status_code=401, headers={"WWW-Authenticate": "Basic"}) + + app.add_middleware(BasicAuthMiddleware) + return app + + if __name__ == "__main__": parser = argparse.ArgumentParser(description="GPS MCP Server") parser.add_argument("--http", action="store_true", help="Use HTTP transport (default: stdio)") @@ -695,6 +771,10 @@ def _configure_http(port: int = 8000) -> None: if args.http: _configure_http(args.port) - mcp.run(transport="streamable-http") + app = mcp.streamable_http_app() + app = _wrap_basic_auth(app) + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=args.port) else: mcp.run(transport="stdio") diff --git a/scripts/build_db.py b/scripts/build_db.py index 5a6523c..bbea19a 100644 --- a/scripts/build_db.py +++ b/scripts/build_db.py @@ -130,6 +130,24 @@ CREATE TABLE jira_scrum_mapping ( component_name TEXT NOT NULL, scrum_team TEXT, specialty TEXT ); +CREATE TABLE scrum_team_board ( + id INTEGER PRIMARY KEY, + organization TEXT, + scrum_team_name TEXT NOT NULL, + jira_board_url TEXT, + pm TEXT, + agilist REAL, + architects REAL, + bff REAL, + backend_engineer REAL, + devops REAL, + manager REAL, + operations_manager REAL, + qe REAL, + staff_engineers REAL, + ui REAL, + total_staff REAL +); -- Jira issues CREATE TABLE jira_issue ( @@ -462,6 +480,62 @@ def parse_jira_scrum_ref(ws) -> list[dict]: return rows +SCRUM_TEAM_BOARDS_TAB = os.environ.get("GPS_SCRUM_TEAM_BOARDS_TAB", "Scrum Team Boards") + +SCRUM_BOARD_COLS = [ + ("organization", "organization"), + ("scrum team name", "scrum_team_name"), + ("jira board", "jira_board_url"), + ("pm", "pm"), + ("agilist", "agilist"), + ("architects", "architects"), + ("bff", "bff"), + ("backend engineer", "backend_engineer"), + ("devops", "devops"), + ("manager", "manager"), + ("operations manager", "operations_manager"), + ("qe", "qe"), + ("staff engineers", "staff_engineers"), + ("ui", "ui"), + ("total staff", "total_staff"), +] + + +def parse_scrum_team_boards(ws) -> list[dict]: + headers = [str(c.value or "").strip().lower() for c in ws[1]] + col_map = {} + for header_key, db_col in SCRUM_BOARD_COLS: + idx = next((i for i, h in enumerate(headers) if h.startswith(header_key)), None) + if idx is not None: + col_map[db_col] = idx + + rows = [] + current_org = None + for row in ws.iter_rows(min_row=2, values_only=True): + org_idx = col_map.get("organization") + name_idx = col_map.get("scrum_team_name") + if name_idx is None or name_idx >= len(row): + continue + team_name = str(row[name_idx] or "").strip() + if not team_name: + continue + + if org_idx is not None and org_idx < len(row) and row[org_idx]: + current_org = str(row[org_idx]).strip() + + record = {"organization": current_org, "scrum_team_name": team_name} + for db_col, idx in col_map.items(): + if db_col in ("organization", "scrum_team_name"): + continue + val = row[idx] if idx < len(row) else None + if db_col in ("jira_board_url", "pm"): + record[db_col] = str(val).strip() if val else None + else: + record[db_col] = float(val) if val is not None else None + rows.append(record) + return rows + + # --------------------------------------------------------------------------- # CSV loaders # --------------------------------------------------------------------------- @@ -1037,6 +1111,19 @@ def build_database(xlsx_path: Path, db_path: Path) -> None: "INSERT INTO jira_scrum_mapping (component_name, scrum_team, specialty) VALUES (?, ?, ?)", (row["component_name"], row["scrum_team"], row["specialty"]), ) + if SCRUM_TEAM_BOARDS_TAB in sheet_names: + ws = wb[SCRUM_TEAM_BOARDS_TAB] + board_rows = parse_scrum_team_boards(ws) + print(f" {SCRUM_TEAM_BOARDS_TAB}: {len(board_rows)} teams") + db_cols = [dc for _, dc in SCRUM_BOARD_COLS] + placeholders = ", ".join("?" for _ in db_cols) + col_names = ", ".join(db_cols) + for row in board_rows: + cur.execute( + f"INSERT INTO scrum_team_board ({col_names}) VALUES ({placeholders})", + tuple(row.get(c) for c in db_cols), + ) + wb.close() # --- CSV data sources --- diff --git a/scripts/test.sh b/scripts/test.sh index a510395..a12e134 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -64,7 +64,7 @@ sys.exit(0 if result == 'ok' else 1) " # 5. Key tables populated -for table in person jira_issue feature release_schedule governance_document; do +for table in person jira_issue feature release_schedule governance_document scrum_team_board; do run "table: $table has rows" uv run python3 -c " import sqlite3, sys conn = sqlite3.connect('$DB_PATH')