Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions data/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
9 changes: 7 additions & 2 deletions deploy/k8s/base/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -35,8 +40,8 @@ spec:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
initialDelaySeconds: 2
periodSeconds: 5
resources:
requests:
memory: "256Mi"
Expand Down
63 changes: 63 additions & 0 deletions docs/QUICKSTART.md
Original file line number Diff line number Diff line change
@@ -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
```
88 changes: 84 additions & 4 deletions mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
),
)

Expand Down Expand Up @@ -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},
)
Expand Down Expand Up @@ -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)")
Expand All @@ -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")
87 changes: 87 additions & 0 deletions scripts/build_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 ---
Expand Down
2 changes: 1 addition & 1 deletion scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading