Skip to content

Commit e4ded27

Browse files
authored
Merge pull request #156 from IBM/flake8
Setup linters github action
2 parents 3bd8612 + 57786f5 commit e4ded27

File tree

8 files changed

+250
-40
lines changed

8 files changed

+250
-40
lines changed

.github/workflows/lint.yml

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# ===============================================================
2+
# 🔍 Lint & Static Analysis – Code Quality Gate
3+
# ===============================================================
4+
#
5+
# - runs each linter in its own matrix job for visibility
6+
# - mirrors the actual CLI commands used locally (no `make`)
7+
# - ensures fast-failure isolation: one failure doesn't hide others
8+
# - each job installs the project in dev-editable mode
9+
# - logs are grouped and plain-text for readability
10+
# ---------------------------------------------------------------
11+
12+
name: Lint & Static Analysis
13+
14+
on:
15+
push:
16+
branches: ["main"]
17+
pull_request:
18+
branches: ["main"]
19+
20+
permissions:
21+
contents: read
22+
23+
jobs:
24+
lint:
25+
strategy:
26+
fail-fast: false
27+
matrix:
28+
include:
29+
# -------------------------------------------------------
30+
# 🧼 Syntax & Format Checkers
31+
# -------------------------------------------------------
32+
- id: yamllint
33+
setup: pip install yamllint
34+
cmd: yamllint -c .yamllint .
35+
36+
- id: jsonlint
37+
setup: |
38+
sudo apt-get update -qq
39+
sudo apt-get install -y jq
40+
cmd: |
41+
find . -type f -name '*.json' -not -path './node_modules/*' -print0 |
42+
xargs -0 -I{} jq empty "{}"
43+
44+
- id: tomllint
45+
setup: pip install tomlcheck
46+
cmd: |
47+
find . -type f -name '*.toml' -print0 |
48+
xargs -0 -I{} tomlcheck "{}"
49+
50+
# -------------------------------------------------------
51+
# 🐍 Python Linters & Type Checkers
52+
# -------------------------------------------------------
53+
- id: flake8
54+
setup: pip install flake8
55+
cmd: flake8 mcpgateway
56+
57+
- id: ruff
58+
setup: pip install ruff
59+
cmd: |
60+
ruff check mcpgateway
61+
62+
# - id: pylint
63+
# setup: pip install pylint
64+
# cmd: pylint mcpgateway
65+
66+
# - id: mypy
67+
# setup: pip install mypy
68+
# cmd: mypy mcpgateway
69+
70+
# - id: pycodestyle
71+
# setup: pip install pycodestyle
72+
# cmd: pycodestyle mcpgateway --max-line-length=200
73+
74+
# - id: pydocstyle
75+
# setup: pip install pydocstyle
76+
# cmd: pydocstyle mcpgateway
77+
78+
# - id: pyright
79+
# setup: npm install -g pyright
80+
# cmd: pyright mcpgateway tests
81+
82+
# -------------------------------------------------------
83+
# 🔒 Security & Packaging Checks
84+
# -------------------------------------------------------
85+
# - id: bandit
86+
# setup: pip install bandit
87+
# cmd: bandit -r mcpgateway
88+
89+
# - id: check-manifest
90+
# setup: pip install check-manifest
91+
# cmd: check-manifest
92+
93+
name: ${{ matrix.id }}
94+
runs-on: ubuntu-latest
95+
96+
steps:
97+
# -----------------------------------------------------------
98+
# 0️⃣ Checkout
99+
# -----------------------------------------------------------
100+
- name: ⬇️ Checkout source
101+
uses: actions/checkout@v4
102+
with:
103+
fetch-depth: 1
104+
105+
# -----------------------------------------------------------
106+
# 1️⃣ Python Setup
107+
# -----------------------------------------------------------
108+
- name: 🐍 Set up Python
109+
uses: actions/setup-python@v5
110+
with:
111+
python-version: "3.12"
112+
cache: pip
113+
114+
# -----------------------------------------------------------
115+
# 2️⃣ Install Project + Dev Dependencies
116+
# -----------------------------------------------------------
117+
- name: 📦 Install project (editable mode)
118+
run: |
119+
python -m pip install --upgrade pip
120+
pip install -e .[dev]
121+
122+
# -----------------------------------------------------------
123+
# 3️⃣ Install Tool-Specific Requirements
124+
# -----------------------------------------------------------
125+
- name: 🔧 Install tool - ${{ matrix.id }}
126+
run: ${{ matrix.setup }}
127+
128+
# -----------------------------------------------------------
129+
# 4️⃣ Run Linter / Validator
130+
# -----------------------------------------------------------
131+
- name: 🔍 Run ${{ matrix.id }}
132+
run: ${{ matrix.cmd }}

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
[![Bandit Security](https://github.com/IBM/mcp-context-forge/actions/workflows/bandit.yml/badge.svg)](https://github.com/IBM/mcp-context-forge/actions/workflows/bandit.yml) 
1111
[![Dependency Review](https://github.com/IBM/mcp-context-forge/actions/workflows/dependency-review.yml/badge.svg)](https://github.com/IBM/mcp-context-forge/actions/workflows/dependency-review.yml) 
1212
[![Tests & Coverage](https://github.com/IBM/mcp-context-forge/actions/workflows/pytest.yml/badge.svg)](https://github.com/IBM/mcp-context-forge/actions/workflows/pytest.yml) 
13+
[![Lint & Static Analysis](https://github.com/IBM/mcp-context-forge/actions/workflows/lint.yml/badge.svg)](https://github.com/IBM/mcp-context-forge/actions/workflows/lint.yml)
1314

1415
<!-- === Container Build & Deploy === -->
1516
[![Secure Docker Build](https://github.com/IBM/mcp-context-forge/actions/workflows/docker-image.yml/badge.svg)](https://github.com/IBM/mcp-context-forge/actions/workflows/docker-image.yml)&nbsp;

mcpgateway/services/gateway_service.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,7 @@ async def _initialize_gateway(self, url: str, authentication: Optional[Dict[str,
646646
Args:
647647
url: Gateway URL
648648
authentication: Optional authentication headers
649+
transport: Transport type ("SSE" or "StreamableHTTP")
649650
650651
Returns:
651652
Capabilities dictionary as provided by the gateway.

mcpgateway/translate.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,17 @@ def _build_fastapi(
165165
# ----- GET /sse ---------------------------------------------------------#
166166
@app.get(sse_path)
167167
async def get_sse(request: Request) -> EventSourceResponse: # noqa: D401
168-
"""Fan-out *stdout* of the subprocess to any number of SSE clients."""
168+
"""Stream subprocess stdout to any number of SSE clients.
169+
170+
Args:
171+
request (Request): The incoming ``GET`` request that will be
172+
upgraded to a Server-Sent Events (SSE) stream.
173+
174+
Returns:
175+
EventSourceResponse: A streaming response that forwards JSON-RPC
176+
messages from the child process and emits periodic ``keepalive``
177+
frames so that clients and proxies do not time out.
178+
"""
169179
queue = pubsub.subscribe()
170180
session_id = uuid.uuid4().hex
171181

@@ -210,7 +220,19 @@ async def event_gen() -> AsyncIterator[dict]:
210220
# ----- POST /message ----------------------------------------------------#
211221
@app.post(message_path, status_code=status.HTTP_202_ACCEPTED)
212222
async def post_message(raw: Request, session_id: str | None = None) -> Response: # noqa: D401
213-
"""Forward the raw JSON body to the stdio process without modification."""
223+
"""Forward a raw JSON-RPC request to the stdio subprocess.
224+
225+
Args:
226+
raw (Request): The incoming ``POST`` request whose body contains
227+
a single JSON-RPC message.
228+
session_id (str | None): The SSE session identifier that originated
229+
this back-channel call (present when the client obtained the
230+
endpoint URL from an ``endpoint`` bootstrap frame).
231+
232+
Returns:
233+
Response: ``202 Accepted`` if the payload is forwarded successfully,
234+
or ``400 Bad Request`` when the body is not valid JSON.
235+
"""
214236
payload = await raw.body()
215237
try:
216238
json.loads(payload) # validate

mcpgateway/transports/streamablehttp_transport.py

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656

5757
server_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("server_id", default=None)
5858

59-
## ------------------------------ Event store ------------------------------
59+
# ------------------------------ Event store ------------------------------
6060

6161

6262
@dataclass
@@ -92,7 +92,16 @@ def __init__(self, max_events_per_stream: int = 100):
9292
self.event_index: dict[EventId, EventEntry] = {}
9393

9494
async def store_event(self, stream_id: StreamId, message: JSONRPCMessage) -> EventId:
95-
"""Stores an event with a generated event ID."""
95+
"""
96+
Stores an event with a generated event ID.
97+
98+
Args:
99+
stream_id (StreamId): The ID of the stream.
100+
message (JSONRPCMessage): The message to store.
101+
102+
Returns:
103+
EventId: The ID of the stored event.
104+
"""
96105
event_id = str(uuid4())
97106
event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message)
98107

@@ -117,7 +126,16 @@ async def replay_events_after(
117126
last_event_id: EventId,
118127
send_callback: EventCallback,
119128
) -> StreamId | None:
120-
"""Replays events that occurred after the specified event ID."""
129+
"""
130+
Replays events that occurred after the specified event ID.
131+
132+
Args:
133+
last_event_id (EventId): The ID of the last received event. Replay starts after this event.
134+
send_callback (EventCallback): Async callback to send each replayed event.
135+
136+
Returns:
137+
StreamId | None: The stream ID if the event is found and replayed, otherwise None.
138+
"""
121139
if last_event_id not in self.event_index:
122140
logger.warning(f"Event ID {last_event_id} not found in store")
123141
return None
@@ -138,7 +156,7 @@ async def replay_events_after(
138156
return stream_id
139157

140158

141-
## ------------------------------ Streamable HTTP Transport ------------------------------
159+
# ------------------------------ Streamable HTTP Transport ------------------------------
142160

143161

144162
@asynccontextmanager
@@ -148,7 +166,7 @@ async def get_db():
148166
149167
Yields:
150168
A database session instance from SessionLocal.
151-
Ensures the session is closed after use.
169+
Ensures the session is closed after use.
152170
"""
153171
db = SessionLocal()
154172
try:
@@ -168,7 +186,7 @@ async def call_tool(name: str, arguments: dict) -> List[Union[types.TextContent,
168186
169187
Returns:
170188
List of content (TextContent, ImageContent, or EmbeddedResource) from the tool response.
171-
Logs and returns an empty list on failure.
189+
Logs and returns an empty list on failure.
172190
"""
173191
try:
174192
async with get_db() as db:
@@ -190,7 +208,7 @@ async def list_tools() -> List[types.Tool]:
190208
191209
Returns:
192210
A list of Tool objects containing metadata such as name, description, and input schema.
193-
Logs and returns an empty list on failure.
211+
Logs and returns an empty list on failure.
194212
"""
195213
server_id = server_id_var.get()
196214

@@ -260,6 +278,10 @@ async def handle_streamable_http(self, scope: Scope, receive: Receive, send: Sen
260278
scope (Scope): ASGI scope object containing connection information.
261279
receive (Receive): ASGI receive callable.
262280
send (Send): ASGI send callable.
281+
282+
Raises:
283+
Exception: Any exception raised during request handling is logged.
284+
263285
Logs any exceptions that occur during request handling.
264286
"""
265287

@@ -277,20 +299,30 @@ async def handle_streamable_http(self, scope: Scope, receive: Receive, send: Sen
277299
raise
278300

279301

280-
## ------------------------- Authentication for /mcp routes ------------------------------
302+
# ------------------------- Authentication for /mcp routes ------------------------------
281303

282304

283305
async def streamable_http_auth(scope, receive, send):
284306
"""
285307
Perform authentication check in middleware context (ASGI scope).
286308
287-
If path does not end with "/mcp", just continue (return True).
309+
This function is intended to be used in middleware wrapping ASGI apps.
310+
It authenticates only requests targeting paths ending in "/mcp" or "/mcp/".
288311
289-
Only check Authorization header for Bearer token.
290-
If no Bearer token provided, allow (return True).
312+
Behavior:
313+
- If the path does not end with "/mcp", authentication is skipped.
314+
- If there is no Authorization header, the request is allowed.
315+
- If a Bearer token is present, it is verified using `verify_credentials`.
316+
- If verification fails, a 401 Unauthorized JSON response is sent.
291317
292-
If auth_required is True and Bearer token provided, verify it.
293-
If verification fails, send 401 JSONResponse and return False.
318+
Args:
319+
scope: The ASGI scope dictionary, which includes request metadata.
320+
receive: ASGI receive callable used to receive events.
321+
send: ASGI send callable used to send events (e.g. a 401 response).
322+
323+
Returns:
324+
bool: True if authentication passes or is skipped.
325+
False if authentication fails and a 401 response is sent.
294326
"""
295327

296328
path = scope.get("path", "")

pyproject.toml

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -220,13 +220,34 @@ profile = "black"
220220
multi_line_output = 3
221221

222222
[tool.mypy]
223-
python_version = "3.10"
224-
warn_return_any = true
225-
warn_unused_configs = true
226-
disallow_untyped_defs = true
227-
strict = true
228-
show_error_codes = true
229-
warn_unreachable = true
223+
# Target Python version
224+
python_version = "3.11"
225+
226+
# Full strictness and individual checks
227+
strict = true # Enable all strict checks
228+
229+
check_untyped_defs = true # Type-check the bodies of untyped functions
230+
no_implicit_optional = true # Require explicit Optional for None default
231+
disallow_untyped_defs = true # Require type annotations for all functions
232+
disallow_untyped_calls = true # Disallow calling functions without type info
233+
disallow_any_unimported = true # Disallow Any from missing imports
234+
warn_return_any = true # Warn if a function returns Any
235+
warn_unreachable = true # Warn about unreachable code
236+
warn_unused_ignores = true # Warn if a "# type: ignore" is unnecessary
237+
warn_unused_configs = true # Warn about unused config options
238+
warn_redundant_casts = true # Warn if a cast does nothing
239+
strict_equality = true # Disallow ==/!= between incompatible types
240+
241+
# Output formatting
242+
show_error_codes = true # Show error codes in output
243+
pretty = true # Format output nicely
244+
245+
# Exclude these paths from analysis
246+
exclude = [
247+
'^build/',
248+
'^\\.venv/',
249+
'^\\.mypy_cache/',
250+
]
230251

231252
[tool.pytest.ini_options]
232253
minversion = "6.0"

0 commit comments

Comments
 (0)