coding-tools-mcp 0.1.3__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.
@@ -1,16 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-tools-mcp
3
- Version: 0.1.3
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
7
- Project-URL: Homepage, https://github.com/ytagent/codex-tool-runtime-mcp
8
- Project-URL: Documentation, https://github.com/ytagent/codex-tool-runtime-mcp/tree/main/docs
9
- Project-URL: Source, https://github.com/ytagent/codex-tool-runtime-mcp
10
- Project-URL: Issues, https://github.com/ytagent/codex-tool-runtime-mcp/issues
7
+ Project-URL: Homepage, https://github.com/xyTom/coding-tools-mcp
8
+ Project-URL: Documentation, https://github.com/xyTom/coding-tools-mcp/tree/main/docs
9
+ Project-URL: Source, https://github.com/xyTom/coding-tools-mcp
10
+ 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"
@@ -47,7 +48,33 @@ It is not a prompt wrapper. It does not expose external agent accounts, memory,
47
48
 
48
49
  ## Quickstart
49
50
 
50
- Run directly with `uvx` against the current directory:
51
+ Install the published command from PyPI:
52
+
53
+ ```bash
54
+ curl -fsSL https://raw.githubusercontent.com/xyTom/coding-tools-mcp/main/scripts/install.sh | bash
55
+ ```
56
+
57
+ Install and start local Streamable HTTP against a workspace:
58
+
59
+ ```bash
60
+ curl -fsSL https://raw.githubusercontent.com/xyTom/coding-tools-mcp/main/scripts/install.sh \
61
+ | bash -s -- --start --workspace /path/to/repo
62
+ ```
63
+
64
+ Install and expose a read-only bearer-token tunnel:
65
+
66
+ ```bash
67
+ curl -fsSL https://raw.githubusercontent.com/xyTom/coding-tools-mcp/main/scripts/install.sh \
68
+ | bash -s -- --tunnel cloudflared --auto-install-tunnel --workspace /path/to/repo
69
+ ```
70
+
71
+ Or, from this checkout:
72
+
73
+ ```bash
74
+ scripts/install.sh
75
+ ```
76
+
77
+ Run the published package without a persistent install:
51
78
 
52
79
  ```bash
53
80
  uvx coding-tools-mcp --workspace .
@@ -157,7 +184,7 @@ scripts/tunnel.sh ngrok /path/to/repo
157
184
  scripts/tunnel.sh devtunnel /path/to/repo
158
185
  ```
159
186
 
160
- 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.
161
188
 
162
189
  See [docs/remote-mcp.md](docs/remote-mcp.md) for the exact modes and security notes.
163
190
 
@@ -1,24 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: coding-tools-mcp
3
- Version: 0.1.3
4
- Summary: Workspace-confined coding tools exposed as an MCP server.
5
- Author: Coding Tools MCP Contributors
6
- License-Expression: LicenseRef-Coding-Tools-MCP-Source-Available
7
- Project-URL: Homepage, https://github.com/ytagent/codex-tool-runtime-mcp
8
- Project-URL: Documentation, https://github.com/ytagent/codex-tool-runtime-mcp/tree/main/docs
9
- Project-URL: Source, https://github.com/ytagent/codex-tool-runtime-mcp
10
- Project-URL: Issues, https://github.com/ytagent/codex-tool-runtime-mcp/issues
11
- Requires-Python: >=3.11
12
- Description-Content-Type: text/markdown
13
- License-File: LICENSE
14
- Provides-Extra: dev
15
- Requires-Dist: mypy<2.2,>=2.1; extra == "dev"
16
- Requires-Dist: ruff<0.16,>=0.15; extra == "dev"
17
- Requires-Dist: typing_extensions>=4.12; extra == "dev"
18
- Provides-Extra: image
19
- Requires-Dist: Pillow>=10.0; extra == "image"
20
- Dynamic: license-file
21
-
22
1
  # Coding Tools MCP
23
2
 
24
3
  Coding Tools MCP is a model-neutral coding-agent runtime MCP server. It exposes local coding primitives to any MCP client:
@@ -47,7 +26,33 @@ It is not a prompt wrapper. It does not expose external agent accounts, memory,
47
26
 
48
27
  ## Quickstart
49
28
 
50
- Run directly with `uvx` against the current directory:
29
+ Install the published command from PyPI:
30
+
31
+ ```bash
32
+ curl -fsSL https://raw.githubusercontent.com/xyTom/coding-tools-mcp/main/scripts/install.sh | bash
33
+ ```
34
+
35
+ Install and start local Streamable HTTP against a workspace:
36
+
37
+ ```bash
38
+ curl -fsSL https://raw.githubusercontent.com/xyTom/coding-tools-mcp/main/scripts/install.sh \
39
+ | bash -s -- --start --workspace /path/to/repo
40
+ ```
41
+
42
+ Install and expose a read-only bearer-token tunnel:
43
+
44
+ ```bash
45
+ curl -fsSL https://raw.githubusercontent.com/xyTom/coding-tools-mcp/main/scripts/install.sh \
46
+ | bash -s -- --tunnel cloudflared --auto-install-tunnel --workspace /path/to/repo
47
+ ```
48
+
49
+ Or, from this checkout:
50
+
51
+ ```bash
52
+ scripts/install.sh
53
+ ```
54
+
55
+ Run the published package without a persistent install:
51
56
 
52
57
  ```bash
53
58
  uvx coding-tools-mcp --workspace .
@@ -157,7 +162,7 @@ scripts/tunnel.sh ngrok /path/to/repo
157
162
  scripts/tunnel.sh devtunnel /path/to/repo
158
163
  ```
159
164
 
160
- 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.
161
166
 
162
167
  See [docs/remote-mcp.md](docs/remote-mcp.md) for the exact modes and security notes.
163
168
 
@@ -1,3 +1,3 @@
1
1
  """Coding Tools MCP server package."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.5"
@@ -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 {"/mcp", "/.well-known/mcp.json", "/.well-known/mcp/server-card.json"}:
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
- if posixpath.normpath(request_path) != "/mcp":
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
- expected = f"Bearer {self.runtime.auth_token}"
4054
- return secrets.compare_digest(header.strip(), expected)
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": 'Bearer realm="coding-tools-mcp"'},
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
- if not auth_token and not is_loopback_bind_host(str(args.host)):
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 CODING_TOOLS_MCP_AUTH_TOKEN.",
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
- auth_label = "bearer auth enabled" if runtime.auth_enabled() else "no auth token configured"
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,3 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: coding-tools-mcp
3
+ Version: 0.1.5
4
+ Summary: Workspace-confined coding tools exposed as an MCP server.
5
+ Author: Coding Tools MCP Contributors
6
+ License-Expression: LicenseRef-Coding-Tools-MCP-Source-Available
7
+ Project-URL: Homepage, https://github.com/xyTom/coding-tools-mcp
8
+ Project-URL: Documentation, https://github.com/xyTom/coding-tools-mcp/tree/main/docs
9
+ Project-URL: Source, https://github.com/xyTom/coding-tools-mcp
10
+ Project-URL: Issues, https://github.com/xyTom/coding-tools-mcp/issues
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: PyJWT>=2.8
15
+ Provides-Extra: dev
16
+ Requires-Dist: mypy<2.2,>=2.1; extra == "dev"
17
+ Requires-Dist: ruff<0.16,>=0.15; extra == "dev"
18
+ Requires-Dist: typing_extensions>=4.12; extra == "dev"
19
+ Provides-Extra: image
20
+ Requires-Dist: Pillow>=10.0; extra == "image"
21
+ Dynamic: license-file
22
+
1
23
  # Coding Tools MCP
2
24
 
3
25
  Coding Tools MCP is a model-neutral coding-agent runtime MCP server. It exposes local coding primitives to any MCP client:
@@ -26,7 +48,33 @@ It is not a prompt wrapper. It does not expose external agent accounts, memory,
26
48
 
27
49
  ## Quickstart
28
50
 
29
- Run directly with `uvx` against the current directory:
51
+ Install the published command from PyPI:
52
+
53
+ ```bash
54
+ curl -fsSL https://raw.githubusercontent.com/xyTom/coding-tools-mcp/main/scripts/install.sh | bash
55
+ ```
56
+
57
+ Install and start local Streamable HTTP against a workspace:
58
+
59
+ ```bash
60
+ curl -fsSL https://raw.githubusercontent.com/xyTom/coding-tools-mcp/main/scripts/install.sh \
61
+ | bash -s -- --start --workspace /path/to/repo
62
+ ```
63
+
64
+ Install and expose a read-only bearer-token tunnel:
65
+
66
+ ```bash
67
+ curl -fsSL https://raw.githubusercontent.com/xyTom/coding-tools-mcp/main/scripts/install.sh \
68
+ | bash -s -- --tunnel cloudflared --auto-install-tunnel --workspace /path/to/repo
69
+ ```
70
+
71
+ Or, from this checkout:
72
+
73
+ ```bash
74
+ scripts/install.sh
75
+ ```
76
+
77
+ Run the published package without a persistent install:
30
78
 
31
79
  ```bash
32
80
  uvx coding-tools-mcp --workspace .
@@ -136,7 +184,7 @@ scripts/tunnel.sh ngrok /path/to/repo
136
184
  scripts/tunnel.sh devtunnel /path/to/repo
137
185
  ```
138
186
 
139
- 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.
140
188
 
141
189
  See [docs/remote-mcp.md](docs/remote-mcp.md) for the exact modes and security notes.
142
190
 
@@ -1,3 +1,4 @@
1
+ PyJWT>=2.8
1
2
 
2
3
  [dev]
3
4
  mypy<2.2,>=2.1
@@ -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.3"
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,18 +4,21 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "coding-tools-mcp"
7
- version = "0.1.3"
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" }]
13
16
 
14
17
  [project.urls]
15
- Homepage = "https://github.com/ytagent/codex-tool-runtime-mcp"
16
- Documentation = "https://github.com/ytagent/codex-tool-runtime-mcp/tree/main/docs"
17
- Source = "https://github.com/ytagent/codex-tool-runtime-mcp"
18
- Issues = "https://github.com/ytagent/codex-tool-runtime-mcp/issues"
18
+ Homepage = "https://github.com/xyTom/coding-tools-mcp"
19
+ Documentation = "https://github.com/xyTom/coding-tools-mcp/tree/main/docs"
20
+ Source = "https://github.com/xyTom/coding-tools-mcp"
21
+ Issues = "https://github.com/xyTom/coding-tools-mcp/issues"
19
22
 
20
23
  [project.optional-dependencies]
21
24
  dev = [