coding-tools-mcp 0.1.4__tar.gz → 0.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/PKG-INFO +3 -2
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/README.md +1 -1
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/coding_tools_mcp/__init__.py +1 -1
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/coding_tools_mcp/server.py +427 -15
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/coding_tools_mcp.egg-info/PKG-INFO +3 -2
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/coding_tools_mcp.egg-info/requires.txt +1 -0
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/docs/profile-v0.1.md +1 -1
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/pyproject.toml +4 -1
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/LICENSE +0 -0
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/coding_tools_mcp/__main__.py +0 -0
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/coding_tools_mcp/landlock_exec.py +0 -0
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/coding_tools_mcp.egg-info/SOURCES.txt +0 -0
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/coding_tools_mcp.egg-info/dependency_links.txt +0 -0
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/coding_tools_mcp.egg-info/entry_points.txt +0 -0
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/coding_tools_mcp.egg-info/top_level.txt +0 -0
- {coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coding-tools-mcp
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Workspace-confined coding tools exposed as an MCP server.
|
|
5
5
|
Author: Coding Tools MCP Contributors
|
|
6
6
|
License-Expression: LicenseRef-Coding-Tools-MCP-Source-Available
|
|
@@ -11,6 +11,7 @@ Project-URL: Issues, https://github.com/xyTom/coding-tools-mcp/issues
|
|
|
11
11
|
Requires-Python: >=3.11
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
13
|
License-File: LICENSE
|
|
14
|
+
Requires-Dist: PyJWT>=2.8
|
|
14
15
|
Provides-Extra: dev
|
|
15
16
|
Requires-Dist: mypy<2.2,>=2.1; extra == "dev"
|
|
16
17
|
Requires-Dist: ruff<0.16,>=0.15; extra == "dev"
|
|
@@ -183,7 +184,7 @@ scripts/tunnel.sh ngrok /path/to/repo
|
|
|
183
184
|
scripts/tunnel.sh devtunnel /path/to/repo
|
|
184
185
|
```
|
|
185
186
|
|
|
186
|
-
For clients that support custom headers, use bearer-token auth with `Authorization: Bearer <token>`. Clients that cannot send custom bearer headers should use anonymous `read-only` mode only for local/testing tunnels, or be placed behind an external auth proxy for production use.
|
|
187
|
+
For clients that support custom headers, use bearer-token auth with `Authorization: Bearer <token>`. For MCP clients that speak OAuth 2.1 Authorization Code + PKCE, use `CODING_TOOLS_MCP_AUTH_MODE=oauth` with `scripts/tunnel.sh` (or `scripts/install.sh --auth-mode oauth`); the only env var you need to set is `CODING_TOOLS_MCP_SERVER_URL` (which must match the tunnel's stable public URL), and the script prints generated `CLIENT_ID`/`CLIENT_SECRET`/`PASSWORD` values on startup. Clients that cannot send custom bearer headers and do not speak OAuth should use anonymous `read-only` mode only for local/testing tunnels, or be placed behind an external auth proxy for production use.
|
|
187
188
|
|
|
188
189
|
See [docs/remote-mcp.md](docs/remote-mcp.md) for the exact modes and security notes.
|
|
189
190
|
|
|
@@ -162,7 +162,7 @@ scripts/tunnel.sh ngrok /path/to/repo
|
|
|
162
162
|
scripts/tunnel.sh devtunnel /path/to/repo
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
-
For clients that support custom headers, use bearer-token auth with `Authorization: Bearer <token>`. Clients that cannot send custom bearer headers should use anonymous `read-only` mode only for local/testing tunnels, or be placed behind an external auth proxy for production use.
|
|
165
|
+
For clients that support custom headers, use bearer-token auth with `Authorization: Bearer <token>`. For MCP clients that speak OAuth 2.1 Authorization Code + PKCE, use `CODING_TOOLS_MCP_AUTH_MODE=oauth` with `scripts/tunnel.sh` (or `scripts/install.sh --auth-mode oauth`); the only env var you need to set is `CODING_TOOLS_MCP_SERVER_URL` (which must match the tunnel's stable public URL), and the script prints generated `CLIENT_ID`/`CLIENT_SECRET`/`PASSWORD` values on startup. Clients that cannot send custom bearer headers and do not speak OAuth should use anonymous `read-only` mode only for local/testing tunnels, or be placed behind an external auth proxy for production use.
|
|
166
166
|
|
|
167
167
|
See [docs/remote-mcp.md](docs/remote-mcp.md) for the exact modes and security notes.
|
|
168
168
|
|
|
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|
|
3
3
|
import argparse
|
|
4
4
|
import base64
|
|
5
5
|
import ctypes
|
|
6
|
+
import hashlib
|
|
7
|
+
import html
|
|
6
8
|
import difflib
|
|
7
9
|
import fnmatch
|
|
8
10
|
import http.server
|
|
@@ -25,6 +27,8 @@ from datetime import datetime, timezone
|
|
|
25
27
|
from pathlib import Path, PurePosixPath
|
|
26
28
|
from typing import Any
|
|
27
29
|
|
|
30
|
+
import jwt
|
|
31
|
+
|
|
28
32
|
from . import __version__
|
|
29
33
|
|
|
30
34
|
|
|
@@ -149,6 +153,45 @@ ENV_FLAG_OPTIONS = {
|
|
|
149
153
|
NETWORK_LITERAL_COMMANDS = {"echo", "printf", "grep", "egrep", "fgrep", "rg", "cat", "head", "tail", "wc"}
|
|
150
154
|
INLINE_SCRIPT_PERMISSION = "inline_script"
|
|
151
155
|
ENV_PREFIX = "CODING_TOOLS_MCP"
|
|
156
|
+
|
|
157
|
+
OAUTH_CODE_TTL_SECONDS = 300
|
|
158
|
+
OAUTH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30
|
|
159
|
+
OAUTH_MAX_BODY_BYTES = 8_192
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class OAuthConfig:
|
|
164
|
+
client_id: str
|
|
165
|
+
client_secret: str
|
|
166
|
+
password: str
|
|
167
|
+
server_url: str
|
|
168
|
+
token_secret: bytes
|
|
169
|
+
token_ttl: int = OAUTH_TOKEN_TTL_SECONDS
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _verify_pkce(code_verifier: str, code_challenge: str) -> bool:
|
|
173
|
+
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
174
|
+
expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
175
|
+
return secrets.compare_digest(expected, code_challenge)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _create_oauth_token(cfg: OAuthConfig) -> str:
|
|
179
|
+
now = int(time.time())
|
|
180
|
+
return jwt.encode(
|
|
181
|
+
{"iss": cfg.server_url, "aud": cfg.server_url, "iat": now, "exp": now + cfg.token_ttl, "scope": "mcp"},
|
|
182
|
+
cfg.token_secret,
|
|
183
|
+
algorithm="HS256",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _validate_oauth_token(token: str, cfg: OAuthConfig) -> bool:
|
|
188
|
+
try:
|
|
189
|
+
jwt.decode(token, cfg.token_secret, algorithms=["HS256"], audience=cfg.server_url, issuer=cfg.server_url)
|
|
190
|
+
return True
|
|
191
|
+
except jwt.PyJWTError:
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
152
195
|
TOOL_PROFILE_CHOICES = ("full", "read-only", "compat-readonly-all")
|
|
153
196
|
FULL_TOOL_NAMES = (
|
|
154
197
|
"server_info",
|
|
@@ -824,6 +867,7 @@ class Runtime:
|
|
|
824
867
|
dangerously_skip_all_permissions: bool = False,
|
|
825
868
|
tool_profile: str = "full",
|
|
826
869
|
auth_token: str | None = None,
|
|
870
|
+
oauth_config: OAuthConfig | None = None,
|
|
827
871
|
) -> None:
|
|
828
872
|
self.workspace = Workspace(workspace)
|
|
829
873
|
self.enable_view_image = enable_view_image
|
|
@@ -837,6 +881,9 @@ class Runtime:
|
|
|
837
881
|
)
|
|
838
882
|
self.tool_profile = tool_profile
|
|
839
883
|
self.auth_token = auth_token or None
|
|
884
|
+
self.oauth_config = oauth_config
|
|
885
|
+
self._pending_codes: dict[str, dict[str, Any]] = {}
|
|
886
|
+
self._pending_codes_lock = threading.Lock()
|
|
840
887
|
self.default_cwd = self.workspace.root
|
|
841
888
|
self.sessions: dict[str, ExecSession] = {}
|
|
842
889
|
self.sessions_lock = threading.Lock()
|
|
@@ -865,7 +912,10 @@ class Runtime:
|
|
|
865
912
|
return [name for name in names if self.enable_view_image or name != "view_image"]
|
|
866
913
|
|
|
867
914
|
def auth_enabled(self) -> bool:
|
|
868
|
-
return self.auth_token is not None
|
|
915
|
+
return self.auth_token is not None or self.oauth_config is not None
|
|
916
|
+
|
|
917
|
+
def oauth_enabled(self) -> bool:
|
|
918
|
+
return self.oauth_config is not None
|
|
869
919
|
|
|
870
920
|
def default_cwd_display(self) -> str:
|
|
871
921
|
return normalize_rel_display(self.default_cwd, self.workspace.root)
|
|
@@ -3755,6 +3805,23 @@ def input_schemas() -> dict[str, dict[str, Any]]:
|
|
|
3755
3805
|
}
|
|
3756
3806
|
|
|
3757
3807
|
|
|
3808
|
+
def _server_card_auth(runtime: Runtime) -> dict[str, Any]:
|
|
3809
|
+
if runtime.oauth_enabled():
|
|
3810
|
+
cfg = runtime.oauth_config
|
|
3811
|
+
assert cfg is not None
|
|
3812
|
+
base = cfg.server_url.rstrip("/")
|
|
3813
|
+
return {
|
|
3814
|
+
"type": "oauth2",
|
|
3815
|
+
"scheme": "Bearer",
|
|
3816
|
+
"header": "Authorization",
|
|
3817
|
+
"authorizationUrl": f"{base}/oauth/authorize",
|
|
3818
|
+
"tokenUrl": f"{base}/oauth/token",
|
|
3819
|
+
}
|
|
3820
|
+
if runtime.auth_token is not None:
|
|
3821
|
+
return {"type": "bearer", "scheme": "Bearer", "header": "Authorization"}
|
|
3822
|
+
return {"type": "none", "scheme": None, "header": None}
|
|
3823
|
+
|
|
3824
|
+
|
|
3758
3825
|
def server_card_payload(runtime: Runtime) -> dict[str, Any]:
|
|
3759
3826
|
names = runtime.exposed_tool_names()
|
|
3760
3827
|
annotations = {name: tool_definition(name, tool_profile=runtime.tool_profile)["annotations"] for name in names}
|
|
@@ -3772,11 +3839,7 @@ def server_card_payload(runtime: Runtime) -> dict[str, Any]:
|
|
|
3772
3839
|
"endpoint": "/mcp",
|
|
3773
3840
|
"methods": ["GET", "HEAD", "POST", "OPTIONS"],
|
|
3774
3841
|
},
|
|
3775
|
-
"auth":
|
|
3776
|
-
"type": "bearer" if runtime.auth_enabled() else "none",
|
|
3777
|
-
"scheme": "Bearer" if runtime.auth_enabled() else None,
|
|
3778
|
-
"header": "Authorization" if runtime.auth_enabled() else None,
|
|
3779
|
-
},
|
|
3842
|
+
"auth": _server_card_auth(runtime),
|
|
3780
3843
|
"toolProfile": runtime.tool_profile,
|
|
3781
3844
|
"tools": {
|
|
3782
3845
|
"count": len(names),
|
|
@@ -3814,7 +3877,15 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|
|
3814
3877
|
|
|
3815
3878
|
def do_OPTIONS(self) -> None:
|
|
3816
3879
|
request_path = self.path.split("?", 1)[0]
|
|
3817
|
-
if posixpath.normpath(request_path) not in {
|
|
3880
|
+
if posixpath.normpath(request_path) not in {
|
|
3881
|
+
"/mcp",
|
|
3882
|
+
"/.well-known/mcp.json",
|
|
3883
|
+
"/.well-known/mcp/server-card.json",
|
|
3884
|
+
"/.well-known/oauth-authorization-server",
|
|
3885
|
+
"/.well-known/oauth-protected-resource",
|
|
3886
|
+
"/oauth/authorize",
|
|
3887
|
+
"/oauth/token",
|
|
3888
|
+
}:
|
|
3818
3889
|
self.send_json({"error": "Unknown endpoint"}, status=404)
|
|
3819
3890
|
return
|
|
3820
3891
|
origin = self.headers.get("Origin")
|
|
@@ -3829,6 +3900,15 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|
|
3829
3900
|
def handle_metadata_request(self, *, head_only: bool) -> None:
|
|
3830
3901
|
request_path = self.path.split("?", 1)[0]
|
|
3831
3902
|
normalized = posixpath.normpath(request_path)
|
|
3903
|
+
if normalized == "/.well-known/oauth-authorization-server":
|
|
3904
|
+
self.handle_oauth_as_metadata(head_only=head_only)
|
|
3905
|
+
return
|
|
3906
|
+
if normalized == "/.well-known/oauth-protected-resource":
|
|
3907
|
+
self.handle_oauth_resource_metadata(head_only=head_only)
|
|
3908
|
+
return
|
|
3909
|
+
if normalized == "/oauth/authorize" and not head_only:
|
|
3910
|
+
self.handle_oauth_authorize_get()
|
|
3911
|
+
return
|
|
3832
3912
|
if normalized == "/mcp":
|
|
3833
3913
|
origin = self.headers.get("Origin")
|
|
3834
3914
|
if origin and not is_allowed_origin(origin, auth_enabled=self.runtime.auth_enabled()):
|
|
@@ -3846,7 +3926,14 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|
|
3846
3926
|
|
|
3847
3927
|
def do_POST(self) -> None:
|
|
3848
3928
|
request_path = self.path.split("?", 1)[0]
|
|
3849
|
-
|
|
3929
|
+
normalized = posixpath.normpath(request_path)
|
|
3930
|
+
if normalized == "/oauth/authorize":
|
|
3931
|
+
self.handle_oauth_authorize_post()
|
|
3932
|
+
return
|
|
3933
|
+
if normalized == "/oauth/token":
|
|
3934
|
+
self.handle_oauth_token()
|
|
3935
|
+
return
|
|
3936
|
+
if normalized != "/mcp":
|
|
3850
3937
|
self.send_json({"jsonrpc": "2.0", "id": None, "error": {"code": -32601, "message": "Unknown endpoint"}}, status=404)
|
|
3851
3938
|
return
|
|
3852
3939
|
origin = self.headers.get("Origin")
|
|
@@ -4049,18 +4136,283 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|
|
4049
4136
|
def is_authorized(self) -> bool:
|
|
4050
4137
|
if not self.runtime.auth_enabled():
|
|
4051
4138
|
return True
|
|
4052
|
-
header = self.headers.get("Authorization", "")
|
|
4053
|
-
|
|
4054
|
-
|
|
4139
|
+
header = self.headers.get("Authorization", "").strip()
|
|
4140
|
+
if self.runtime.auth_token is not None:
|
|
4141
|
+
if secrets.compare_digest(header, f"Bearer {self.runtime.auth_token}"):
|
|
4142
|
+
return True
|
|
4143
|
+
if self.runtime.oauth_config is not None and header.startswith("Bearer "):
|
|
4144
|
+
token = header[len("Bearer "):]
|
|
4145
|
+
if _validate_oauth_token(token, self.runtime.oauth_config):
|
|
4146
|
+
return True
|
|
4147
|
+
return False
|
|
4055
4148
|
|
|
4056
4149
|
def send_unauthorized(self, *, head_only: bool = False) -> None:
|
|
4150
|
+
if self.runtime.oauth_config is not None:
|
|
4151
|
+
base = self.runtime.oauth_config.server_url.rstrip("/")
|
|
4152
|
+
www_auth = f'Bearer realm="coding-tools-mcp", resource_metadata="{base}/.well-known/oauth-protected-resource"'
|
|
4153
|
+
else:
|
|
4154
|
+
www_auth = 'Bearer realm="coding-tools-mcp"'
|
|
4057
4155
|
self.send_json(
|
|
4058
4156
|
{"jsonrpc": "2.0", "id": None, "error": {"code": -32000, "message": "Unauthorized"}},
|
|
4059
4157
|
status=401,
|
|
4060
|
-
extra_headers={"WWW-Authenticate":
|
|
4158
|
+
extra_headers={"WWW-Authenticate": www_auth},
|
|
4159
|
+
head_only=head_only,
|
|
4160
|
+
)
|
|
4161
|
+
|
|
4162
|
+
def handle_oauth_as_metadata(self, *, head_only: bool = False) -> None:
|
|
4163
|
+
cfg = self.runtime.oauth_config
|
|
4164
|
+
if cfg is None:
|
|
4165
|
+
self.send_json({"error": "OAuth not configured"}, status=404, head_only=head_only)
|
|
4166
|
+
return
|
|
4167
|
+
base = cfg.server_url.rstrip("/")
|
|
4168
|
+
self.send_json(
|
|
4169
|
+
{
|
|
4170
|
+
"issuer": base,
|
|
4171
|
+
"authorization_endpoint": f"{base}/oauth/authorize",
|
|
4172
|
+
"token_endpoint": f"{base}/oauth/token",
|
|
4173
|
+
"response_types_supported": ["code"],
|
|
4174
|
+
"grant_types_supported": ["authorization_code"],
|
|
4175
|
+
"code_challenge_methods_supported": ["S256"],
|
|
4176
|
+
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
|
|
4177
|
+
},
|
|
4178
|
+
head_only=head_only,
|
|
4179
|
+
)
|
|
4180
|
+
|
|
4181
|
+
def handle_oauth_resource_metadata(self, *, head_only: bool = False) -> None:
|
|
4182
|
+
cfg = self.runtime.oauth_config
|
|
4183
|
+
if cfg is None:
|
|
4184
|
+
self.send_json({"error": "OAuth not configured"}, status=404, head_only=head_only)
|
|
4185
|
+
return
|
|
4186
|
+
base = cfg.server_url.rstrip("/")
|
|
4187
|
+
self.send_json(
|
|
4188
|
+
{"resource": base, "authorization_servers": [base], "bearer_methods_supported": ["header"]},
|
|
4061
4189
|
head_only=head_only,
|
|
4062
4190
|
)
|
|
4063
4191
|
|
|
4192
|
+
def _send_html(self, body: str, *, status: int = 200) -> None:
|
|
4193
|
+
data = body.encode("utf-8")
|
|
4194
|
+
self.send_response(status)
|
|
4195
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
4196
|
+
self.send_header("Content-Length", str(len(data)))
|
|
4197
|
+
self.send_header("Cache-Control", "no-store")
|
|
4198
|
+
self.end_headers()
|
|
4199
|
+
self.wfile.write(data)
|
|
4200
|
+
|
|
4201
|
+
def _oauth_login_page(self, *, client_id: str, redirect_uri: str, code_challenge: str,
|
|
4202
|
+
code_challenge_method: str, state: str, error: str = "") -> str:
|
|
4203
|
+
def esc(v: str) -> str:
|
|
4204
|
+
return html.escape(v, quote=True)
|
|
4205
|
+
error_block = f'<p style="color:red">{html.escape(error)}</p>' if error else ""
|
|
4206
|
+
return (
|
|
4207
|
+
"<!DOCTYPE html><html lang='en'><head><meta charset='utf-8'>"
|
|
4208
|
+
"<title>Authorize MCP Server</title>"
|
|
4209
|
+
"<style>body{font-family:sans-serif;max-width:380px;margin:4rem auto;padding:1rem}"
|
|
4210
|
+
"input{width:100%;padding:.5rem;margin:.4rem 0;box-sizing:border-box}"
|
|
4211
|
+
"button{width:100%;padding:.7rem;background:#0066cc;color:#fff;border:none;cursor:pointer}</style>"
|
|
4212
|
+
"</head><body>"
|
|
4213
|
+
f"<h2>Authorize Coding Tools MCP</h2>"
|
|
4214
|
+
f"<p>Client: <strong>{esc(client_id)}</strong></p>"
|
|
4215
|
+
f"{error_block}"
|
|
4216
|
+
"<form method='POST' action='/oauth/authorize'>"
|
|
4217
|
+
f"<input type='hidden' name='client_id' value='{esc(client_id)}'>"
|
|
4218
|
+
f"<input type='hidden' name='redirect_uri' value='{esc(redirect_uri)}'>"
|
|
4219
|
+
f"<input type='hidden' name='code_challenge' value='{esc(code_challenge)}'>"
|
|
4220
|
+
f"<input type='hidden' name='code_challenge_method' value='{esc(code_challenge_method)}'>"
|
|
4221
|
+
f"<input type='hidden' name='state' value='{esc(state)}'>"
|
|
4222
|
+
"<label>Password<input type='password' name='password' autocomplete='current-password' required></label>"
|
|
4223
|
+
"<button type='submit'>Authorize</button>"
|
|
4224
|
+
"</form></body></html>"
|
|
4225
|
+
)
|
|
4226
|
+
|
|
4227
|
+
def _read_oauth_body(self) -> bytes | None:
|
|
4228
|
+
raw_len = self.headers.get("Content-Length")
|
|
4229
|
+
if raw_len is None:
|
|
4230
|
+
self.send_json({"error": "Content-Length required"}, status=411)
|
|
4231
|
+
return None
|
|
4232
|
+
try:
|
|
4233
|
+
length = int(raw_len)
|
|
4234
|
+
except ValueError:
|
|
4235
|
+
self.send_json({"error": "Invalid Content-Length"}, status=400)
|
|
4236
|
+
return None
|
|
4237
|
+
if not (0 <= length <= OAUTH_MAX_BODY_BYTES):
|
|
4238
|
+
self.send_json({"error": "Request body too large"}, status=413)
|
|
4239
|
+
return None
|
|
4240
|
+
return self.rfile.read(length)
|
|
4241
|
+
|
|
4242
|
+
def handle_oauth_authorize_get(self) -> None:
|
|
4243
|
+
cfg = self.runtime.oauth_config
|
|
4244
|
+
if cfg is None:
|
|
4245
|
+
self.send_json({"error": "OAuth not configured"}, status=404)
|
|
4246
|
+
return
|
|
4247
|
+
params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query, keep_blank_values=True)
|
|
4248
|
+
|
|
4249
|
+
def _p(k: str) -> str:
|
|
4250
|
+
v = params.get(k)
|
|
4251
|
+
return v[0] if v else ""
|
|
4252
|
+
|
|
4253
|
+
client_id = _p("client_id")
|
|
4254
|
+
redirect_uri = _p("redirect_uri")
|
|
4255
|
+
code_challenge = _p("code_challenge")
|
|
4256
|
+
code_challenge_method = _p("code_challenge_method")
|
|
4257
|
+
state = _p("state")
|
|
4258
|
+
|
|
4259
|
+
if _p("response_type") != "code":
|
|
4260
|
+
self._send_html("<h2>Error</h2><p>response_type must be 'code'</p>", status=400)
|
|
4261
|
+
return
|
|
4262
|
+
if not secrets.compare_digest(client_id, cfg.client_id):
|
|
4263
|
+
self._send_html("<h2>Error</h2><p>Unknown client_id</p>", status=400)
|
|
4264
|
+
return
|
|
4265
|
+
if code_challenge_method != "S256" or not code_challenge:
|
|
4266
|
+
self._send_html("<h2>Error</h2><p>code_challenge_method must be S256 and code_challenge is required</p>", status=400)
|
|
4267
|
+
return
|
|
4268
|
+
|
|
4269
|
+
self._send_html(self._oauth_login_page(
|
|
4270
|
+
client_id=client_id, redirect_uri=redirect_uri, code_challenge=code_challenge,
|
|
4271
|
+
code_challenge_method=code_challenge_method, state=state,
|
|
4272
|
+
))
|
|
4273
|
+
|
|
4274
|
+
def handle_oauth_authorize_post(self) -> None:
|
|
4275
|
+
cfg = self.runtime.oauth_config
|
|
4276
|
+
if cfg is None:
|
|
4277
|
+
self.send_json({"error": "OAuth not configured"}, status=404)
|
|
4278
|
+
return
|
|
4279
|
+
body = self._read_oauth_body()
|
|
4280
|
+
if body is None:
|
|
4281
|
+
return
|
|
4282
|
+
params = urllib.parse.parse_qs(body.decode("utf-8", errors="replace"), keep_blank_values=True)
|
|
4283
|
+
|
|
4284
|
+
def _p(k: str) -> str:
|
|
4285
|
+
v = params.get(k)
|
|
4286
|
+
return v[0] if v else ""
|
|
4287
|
+
|
|
4288
|
+
client_id = _p("client_id")
|
|
4289
|
+
redirect_uri = _p("redirect_uri")
|
|
4290
|
+
code_challenge = _p("code_challenge")
|
|
4291
|
+
code_challenge_method = _p("code_challenge_method")
|
|
4292
|
+
state = _p("state")
|
|
4293
|
+
password = _p("password")
|
|
4294
|
+
|
|
4295
|
+
if not secrets.compare_digest(client_id, cfg.client_id):
|
|
4296
|
+
self._send_html(self._oauth_login_page(
|
|
4297
|
+
client_id=client_id, redirect_uri=redirect_uri, code_challenge=code_challenge,
|
|
4298
|
+
code_challenge_method=code_challenge_method, state=state, error="Invalid client",
|
|
4299
|
+
), status=400)
|
|
4300
|
+
return
|
|
4301
|
+
if code_challenge_method != "S256" or not code_challenge:
|
|
4302
|
+
self._send_html(self._oauth_login_page(
|
|
4303
|
+
client_id=client_id, redirect_uri=redirect_uri, code_challenge=code_challenge,
|
|
4304
|
+
code_challenge_method=code_challenge_method, state=state, error="Invalid PKCE parameters",
|
|
4305
|
+
), status=400)
|
|
4306
|
+
return
|
|
4307
|
+
if not secrets.compare_digest(password, cfg.password):
|
|
4308
|
+
self._send_html(self._oauth_login_page(
|
|
4309
|
+
client_id=client_id, redirect_uri=redirect_uri, code_challenge=code_challenge,
|
|
4310
|
+
code_challenge_method=code_challenge_method, state=state, error="Invalid password",
|
|
4311
|
+
), status=401)
|
|
4312
|
+
return
|
|
4313
|
+
|
|
4314
|
+
code = secrets.token_urlsafe(32)
|
|
4315
|
+
now = time.time()
|
|
4316
|
+
with self.runtime._pending_codes_lock:
|
|
4317
|
+
expired = [k for k, v in self.runtime._pending_codes.items() if v["expires_at"] < now]
|
|
4318
|
+
for k in expired:
|
|
4319
|
+
del self.runtime._pending_codes[k]
|
|
4320
|
+
self.runtime._pending_codes[code] = {
|
|
4321
|
+
"code_challenge": code_challenge,
|
|
4322
|
+
"client_id": client_id,
|
|
4323
|
+
"redirect_uri": redirect_uri,
|
|
4324
|
+
"state": state,
|
|
4325
|
+
"expires_at": now + OAUTH_CODE_TTL_SECONDS,
|
|
4326
|
+
}
|
|
4327
|
+
|
|
4328
|
+
qs = urllib.parse.urlencode({"code": code, **({"state": state} if state else {})})
|
|
4329
|
+
sep = "&" if "?" in redirect_uri else "?"
|
|
4330
|
+
location = redirect_uri + sep + qs
|
|
4331
|
+
self.send_response(302)
|
|
4332
|
+
self.send_header("Location", location)
|
|
4333
|
+
self.send_header("Cache-Control", "no-store")
|
|
4334
|
+
self.send_header("Content-Length", "0")
|
|
4335
|
+
self.end_headers()
|
|
4336
|
+
|
|
4337
|
+
def handle_oauth_token(self) -> None:
|
|
4338
|
+
cfg = self.runtime.oauth_config
|
|
4339
|
+
if cfg is None:
|
|
4340
|
+
self.send_json({"error": "unsupported_grant_type"}, status=400)
|
|
4341
|
+
return
|
|
4342
|
+
body = self._read_oauth_body()
|
|
4343
|
+
if body is None:
|
|
4344
|
+
return
|
|
4345
|
+
content_type = self.headers.get("Content-Type", "").split(";")[0].strip().lower()
|
|
4346
|
+
if content_type != "application/x-www-form-urlencoded":
|
|
4347
|
+
self.send_json({"error": "invalid_request", "error_description": "Content-Type must be application/x-www-form-urlencoded"}, status=400)
|
|
4348
|
+
return
|
|
4349
|
+
params = urllib.parse.parse_qs(body.decode("utf-8", errors="replace"), keep_blank_values=True)
|
|
4350
|
+
|
|
4351
|
+
def _p(k: str) -> str:
|
|
4352
|
+
v = params.get(k)
|
|
4353
|
+
return v[0] if v else ""
|
|
4354
|
+
|
|
4355
|
+
grant_type = _p("grant_type")
|
|
4356
|
+
code = _p("code")
|
|
4357
|
+
redirect_uri = _p("redirect_uri")
|
|
4358
|
+
code_verifier = _p("code_verifier")
|
|
4359
|
+
client_id = _p("client_id")
|
|
4360
|
+
client_secret = _p("client_secret")
|
|
4361
|
+
|
|
4362
|
+
# Also accept HTTP Basic auth for client credentials
|
|
4363
|
+
auth_header = self.headers.get("Authorization", "")
|
|
4364
|
+
if auth_header.startswith("Basic ") and (not client_id or not client_secret):
|
|
4365
|
+
try:
|
|
4366
|
+
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
|
|
4367
|
+
basic_id, _, basic_secret = decoded.partition(":")
|
|
4368
|
+
if not client_id:
|
|
4369
|
+
client_id = urllib.parse.unquote(basic_id)
|
|
4370
|
+
if not client_secret:
|
|
4371
|
+
client_secret = urllib.parse.unquote(basic_secret)
|
|
4372
|
+
except Exception: # noqa: BLE001
|
|
4373
|
+
pass
|
|
4374
|
+
|
|
4375
|
+
def _err(error: str, description: str) -> None:
|
|
4376
|
+
self.send_json({"error": error, "error_description": description}, status=400)
|
|
4377
|
+
|
|
4378
|
+
if grant_type != "authorization_code":
|
|
4379
|
+
_err("unsupported_grant_type", "Only authorization_code is supported")
|
|
4380
|
+
return
|
|
4381
|
+
if not secrets.compare_digest(client_id, cfg.client_id):
|
|
4382
|
+
_err("invalid_client", "Unknown client_id")
|
|
4383
|
+
return
|
|
4384
|
+
if not secrets.compare_digest(client_secret, cfg.client_secret):
|
|
4385
|
+
_err("invalid_client", "Invalid client_secret")
|
|
4386
|
+
return
|
|
4387
|
+
if not code:
|
|
4388
|
+
_err("invalid_grant", "code is required")
|
|
4389
|
+
return
|
|
4390
|
+
if not code_verifier or not (43 <= len(code_verifier) <= 128) or not re.fullmatch(r"[A-Za-z0-9\-._~]+", code_verifier):
|
|
4391
|
+
_err("invalid_grant", "Invalid code_verifier")
|
|
4392
|
+
return
|
|
4393
|
+
|
|
4394
|
+
with self.runtime._pending_codes_lock:
|
|
4395
|
+
code_data = self.runtime._pending_codes.pop(code, None)
|
|
4396
|
+
|
|
4397
|
+
if code_data is None:
|
|
4398
|
+
_err("invalid_grant", "Unknown or already-used authorization code")
|
|
4399
|
+
return
|
|
4400
|
+
if time.time() > code_data["expires_at"]:
|
|
4401
|
+
_err("invalid_grant", "Authorization code expired")
|
|
4402
|
+
return
|
|
4403
|
+
if not secrets.compare_digest(code_data["client_id"], client_id):
|
|
4404
|
+
_err("invalid_grant", "client_id mismatch")
|
|
4405
|
+
return
|
|
4406
|
+
if not secrets.compare_digest(code_data["redirect_uri"], redirect_uri):
|
|
4407
|
+
_err("invalid_grant", "redirect_uri mismatch")
|
|
4408
|
+
return
|
|
4409
|
+
if not _verify_pkce(code_verifier, code_data["code_challenge"]):
|
|
4410
|
+
_err("invalid_grant", "PKCE verification failed")
|
|
4411
|
+
return
|
|
4412
|
+
|
|
4413
|
+
access_token = _create_oauth_token(cfg)
|
|
4414
|
+
self.send_json({"access_token": access_token, "token_type": "Bearer", "expires_in": cfg.token_ttl})
|
|
4415
|
+
|
|
4064
4416
|
def send_cors_headers(self) -> None:
|
|
4065
4417
|
origin = self.headers.get("Origin")
|
|
4066
4418
|
if origin and is_allowed_origin(origin, auth_enabled=self.runtime.auth_enabled()):
|
|
@@ -4104,9 +4456,53 @@ class RuntimeHTTPServer(http.server.ThreadingHTTPServer):
|
|
|
4104
4456
|
def run_http(args: argparse.Namespace) -> int:
|
|
4105
4457
|
workspace = Path(args.workspace or os.environ.get("CODING_TOOLS_MCP_WORKSPACE") or os.getcwd())
|
|
4106
4458
|
auth_token = args.auth_token or os.environ.get(f"{ENV_PREFIX}_AUTH_TOKEN") or None
|
|
4107
|
-
|
|
4459
|
+
|
|
4460
|
+
oauth_config: OAuthConfig | None = None
|
|
4461
|
+
oauth_mode = getattr(args, "oauth_mode", False) or os.environ.get(f"{ENV_PREFIX}_OAUTH_MODE") == "1"
|
|
4462
|
+
if oauth_mode:
|
|
4463
|
+
client_id = os.environ.get(f"{ENV_PREFIX}_OAUTH_CLIENT_ID") or ""
|
|
4464
|
+
client_secret = os.environ.get(f"{ENV_PREFIX}_OAUTH_CLIENT_SECRET") or ""
|
|
4465
|
+
password = os.environ.get(f"{ENV_PREFIX}_OAUTH_PASSWORD") or ""
|
|
4466
|
+
server_url = os.environ.get(f"{ENV_PREFIX}_SERVER_URL") or ""
|
|
4467
|
+
if not all([client_id, client_secret, password, server_url]):
|
|
4468
|
+
print(
|
|
4469
|
+
"ERROR: --oauth-mode requires CODING_TOOLS_MCP_OAUTH_CLIENT_ID, "
|
|
4470
|
+
"CODING_TOOLS_MCP_OAUTH_CLIENT_SECRET, CODING_TOOLS_MCP_OAUTH_PASSWORD, "
|
|
4471
|
+
"and CODING_TOOLS_MCP_SERVER_URL.",
|
|
4472
|
+
file=sys.stderr,
|
|
4473
|
+
)
|
|
4474
|
+
return 2
|
|
4475
|
+
raw_secret = os.environ.get(f"{ENV_PREFIX}_OAUTH_TOKEN_SECRET") or ""
|
|
4476
|
+
if raw_secret:
|
|
4477
|
+
try:
|
|
4478
|
+
token_secret = bytes.fromhex(raw_secret)
|
|
4479
|
+
except ValueError:
|
|
4480
|
+
print(
|
|
4481
|
+
f"ERROR: {ENV_PREFIX}_OAUTH_TOKEN_SECRET must be hex-encoded bytes.",
|
|
4482
|
+
file=sys.stderr,
|
|
4483
|
+
)
|
|
4484
|
+
return 2
|
|
4485
|
+
else:
|
|
4486
|
+
token_secret = secrets.token_bytes(32)
|
|
4487
|
+
try:
|
|
4488
|
+
token_ttl = int(os.environ.get(f"{ENV_PREFIX}_OAUTH_TOKEN_TTL") or OAUTH_TOKEN_TTL_SECONDS)
|
|
4489
|
+
except ValueError:
|
|
4490
|
+
token_ttl = OAUTH_TOKEN_TTL_SECONDS
|
|
4491
|
+
oauth_config = OAuthConfig(
|
|
4492
|
+
client_id=client_id,
|
|
4493
|
+
client_secret=client_secret,
|
|
4494
|
+
password=password,
|
|
4495
|
+
server_url=server_url.rstrip("/"),
|
|
4496
|
+
token_secret=token_secret,
|
|
4497
|
+
token_ttl=token_ttl,
|
|
4498
|
+
)
|
|
4499
|
+
if auth_token:
|
|
4500
|
+
print("WARNING: --auth-token is ignored when --oauth-mode is active.", file=sys.stderr)
|
|
4501
|
+
auth_token = None
|
|
4502
|
+
|
|
4503
|
+
if not auth_token and not oauth_config and not is_loopback_bind_host(str(args.host)):
|
|
4108
4504
|
print(
|
|
4109
|
-
"ERROR: non-loopback HTTP binding requires --auth-token or
|
|
4505
|
+
"ERROR: non-loopback HTTP binding requires --auth-token, CODING_TOOLS_MCP_AUTH_TOKEN, or --oauth-mode.",
|
|
4110
4506
|
file=sys.stderr,
|
|
4111
4507
|
)
|
|
4112
4508
|
return 2
|
|
@@ -4116,6 +4512,7 @@ def run_http(args: argparse.Namespace) -> int:
|
|
|
4116
4512
|
dangerously_skip_all_permissions=args.dangerously_skip_all_permissions,
|
|
4117
4513
|
tool_profile=args.tool_profile,
|
|
4118
4514
|
auth_token=auth_token,
|
|
4515
|
+
oauth_config=oauth_config,
|
|
4119
4516
|
)
|
|
4120
4517
|
server = RuntimeHTTPServer((args.host, args.port), MCPHandler, runtime)
|
|
4121
4518
|
if args.dangerously_skip_all_permissions:
|
|
@@ -4123,7 +4520,12 @@ def run_http(args: argparse.Namespace) -> int:
|
|
|
4123
4520
|
"WARNING: --dangerously-skip-all-permissions is enabled; permission-gated operations will be auto-granted.",
|
|
4124
4521
|
file=sys.stderr,
|
|
4125
4522
|
)
|
|
4126
|
-
|
|
4523
|
+
if oauth_config:
|
|
4524
|
+
auth_label = f"oauth2 enabled (server_url={oauth_config.server_url})"
|
|
4525
|
+
elif runtime.auth_token:
|
|
4526
|
+
auth_label = "bearer auth enabled"
|
|
4527
|
+
else:
|
|
4528
|
+
auth_label = "no auth configured"
|
|
4127
4529
|
print(f"{SERVER_NAME} listening on http://{args.host}:{args.port}/mcp ({auth_label}, profile={args.tool_profile})", file=sys.stderr)
|
|
4128
4530
|
try:
|
|
4129
4531
|
server.serve_forever()
|
|
@@ -4233,6 +4635,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
4233
4635
|
default=None,
|
|
4234
4636
|
help=f"require Authorization: Bearer <token> on /mcp; defaults to {ENV_PREFIX}_AUTH_TOKEN",
|
|
4235
4637
|
)
|
|
4638
|
+
parser.add_argument(
|
|
4639
|
+
"--oauth-mode",
|
|
4640
|
+
action="store_true",
|
|
4641
|
+
default=False,
|
|
4642
|
+
help=(
|
|
4643
|
+
"enable OAuth 2.1 Authorization Code + PKCE; "
|
|
4644
|
+
f"requires env vars {ENV_PREFIX}_OAUTH_CLIENT_ID, {ENV_PREFIX}_OAUTH_CLIENT_SECRET, "
|
|
4645
|
+
f"{ENV_PREFIX}_OAUTH_PASSWORD, {ENV_PREFIX}_SERVER_URL"
|
|
4646
|
+
),
|
|
4647
|
+
)
|
|
4236
4648
|
parser.add_argument(
|
|
4237
4649
|
"--tool-profile",
|
|
4238
4650
|
choices=TOOL_PROFILE_CHOICES,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coding-tools-mcp
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Workspace-confined coding tools exposed as an MCP server.
|
|
5
5
|
Author: Coding Tools MCP Contributors
|
|
6
6
|
License-Expression: LicenseRef-Coding-Tools-MCP-Source-Available
|
|
@@ -11,6 +11,7 @@ Project-URL: Issues, https://github.com/xyTom/coding-tools-mcp/issues
|
|
|
11
11
|
Requires-Python: >=3.11
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
13
|
License-File: LICENSE
|
|
14
|
+
Requires-Dist: PyJWT>=2.8
|
|
14
15
|
Provides-Extra: dev
|
|
15
16
|
Requires-Dist: mypy<2.2,>=2.1; extra == "dev"
|
|
16
17
|
Requires-Dist: ruff<0.16,>=0.15; extra == "dev"
|
|
@@ -183,7 +184,7 @@ scripts/tunnel.sh ngrok /path/to/repo
|
|
|
183
184
|
scripts/tunnel.sh devtunnel /path/to/repo
|
|
184
185
|
```
|
|
185
186
|
|
|
186
|
-
For clients that support custom headers, use bearer-token auth with `Authorization: Bearer <token>`. Clients that cannot send custom bearer headers should use anonymous `read-only` mode only for local/testing tunnels, or be placed behind an external auth proxy for production use.
|
|
187
|
+
For clients that support custom headers, use bearer-token auth with `Authorization: Bearer <token>`. For MCP clients that speak OAuth 2.1 Authorization Code + PKCE, use `CODING_TOOLS_MCP_AUTH_MODE=oauth` with `scripts/tunnel.sh` (or `scripts/install.sh --auth-mode oauth`); the only env var you need to set is `CODING_TOOLS_MCP_SERVER_URL` (which must match the tunnel's stable public URL), and the script prints generated `CLIENT_ID`/`CLIENT_SECRET`/`PASSWORD` values on startup. Clients that cannot send custom bearer headers and do not speak OAuth should use anonymous `read-only` mode only for local/testing tunnels, or be placed behind an external auth proxy for production use.
|
|
187
188
|
|
|
188
189
|
See [docs/remote-mcp.md](docs/remote-mcp.md) for the exact modes and security notes.
|
|
189
190
|
|
|
@@ -54,7 +54,7 @@ If a client requests a newer date-based protocol revision, the server negotiates
|
|
|
54
54
|
"serverInfo": {
|
|
55
55
|
"name": "coding-tools-mcp",
|
|
56
56
|
"title": "Coding Tools MCP",
|
|
57
|
-
"version": "0.1.
|
|
57
|
+
"version": "0.1.5"
|
|
58
58
|
},
|
|
59
59
|
"instructions": "Use these tools only for local coding operations inside the configured workspace."
|
|
60
60
|
}
|
|
@@ -4,9 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "coding-tools-mcp"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
8
8
|
description = "Workspace-confined coding tools exposed as an MCP server."
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"PyJWT>=2.8",
|
|
12
|
+
]
|
|
10
13
|
readme = "README.md"
|
|
11
14
|
license = "LicenseRef-Coding-Tools-MCP-Source-Available"
|
|
12
15
|
authors = [{ name = "Coding Tools MCP Contributors" }]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/coding_tools_mcp.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{coding_tools_mcp-0.1.4 → coding_tools_mcp-0.1.5}/coding_tools_mcp.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|