hoja-cli 0.21.4__py3-none-any.whl

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.
Files changed (107) hide show
  1. VERSION +1 -0
  2. google_workspace/__init__.py +19 -0
  3. google_workspace/client.py +253 -0
  4. google_workspace/config.py +39 -0
  5. google_workspace/errors.py +19 -0
  6. google_workspace/mcp_server.py +100 -0
  7. google_workspace/oauth.py +131 -0
  8. google_workspace/principal.py +83 -0
  9. google_workspace/py.typed +0 -0
  10. google_workspace/tools.py +64 -0
  11. google_workspace/writes.py +44 -0
  12. hoja/__init__.py +29 -0
  13. hoja/__main__.py +6 -0
  14. hoja/auth.py +42 -0
  15. hoja/cli.py +161 -0
  16. hoja/cli_state.py +55 -0
  17. hoja/client.py +268 -0
  18. hoja/commands/__init__.py +4 -0
  19. hoja/commands/auth_cmd.py +271 -0
  20. hoja/commands/comment.py +149 -0
  21. hoja/commands/cycle.py +75 -0
  22. hoja/commands/init.py +108 -0
  23. hoja/commands/issue.py +398 -0
  24. hoja/commands/label.py +163 -0
  25. hoja/commands/memory.py +240 -0
  26. hoja/commands/project.py +56 -0
  27. hoja/commands/raw.py +50 -0
  28. hoja/commands/team.py +102 -0
  29. hoja/commands/update.py +162 -0
  30. hoja/config.py +362 -0
  31. hoja/errors.py +107 -0
  32. hoja/filters.py +150 -0
  33. hoja/graphql/__init__.py +5 -0
  34. hoja/graphql/fragments.py +138 -0
  35. hoja/graphql/mutations.py +160 -0
  36. hoja/graphql/queries.py +245 -0
  37. hoja/mcp/__init__.py +27 -0
  38. hoja/mcp/content.py +151 -0
  39. hoja/mcp/prompts.py +298 -0
  40. hoja/mcp/resources.py +213 -0
  41. hoja/mcp/server.py +189 -0
  42. hoja/memory_board.py +370 -0
  43. hoja/models/__init__.py +37 -0
  44. hoja/models/base.py +64 -0
  45. hoja/models/cycle.py +32 -0
  46. hoja/models/enums.py +62 -0
  47. hoja/models/issue.py +99 -0
  48. hoja/models/label.py +17 -0
  49. hoja/models/project.py +60 -0
  50. hoja/models/team.py +31 -0
  51. hoja/models/user.py +32 -0
  52. hoja/models/workflow.py +65 -0
  53. hoja/output.py +410 -0
  54. hoja/skill_content/commands.md +222 -0
  55. hoja/skill_content/examples.md +193 -0
  56. hoja/skill_content/filters.md +101 -0
  57. hoja/skill_content/output-formats.md +92 -0
  58. hoja/skill_content/overview.md +123 -0
  59. hoja/skill_content/workflows.md +174 -0
  60. hoja_cli-0.21.4.dist-info/METADATA +279 -0
  61. hoja_cli-0.21.4.dist-info/RECORD +107 -0
  62. hoja_cli-0.21.4.dist-info/WHEEL +4 -0
  63. hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
  64. hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
  65. hoja_server/README.md +135 -0
  66. hoja_server/__init__.py +11 -0
  67. hoja_server/abac.py +116 -0
  68. hoja_server/agent_chat.py +280 -0
  69. hoja_server/agent_conversations.py +91 -0
  70. hoja_server/agent_durable.py +332 -0
  71. hoja_server/agent_prompt.py +117 -0
  72. hoja_server/agent_threads.py +96 -0
  73. hoja_server/agent_uploads.py +60 -0
  74. hoja_server/app.py +2189 -0
  75. hoja_server/cadence.py +231 -0
  76. hoja_server/cli.py +191 -0
  77. hoja_server/cloudflare_realtime.py +110 -0
  78. hoja_server/comment_policy.py +70 -0
  79. hoja_server/connector_actions.py +119 -0
  80. hoja_server/connectors_api.py +375 -0
  81. hoja_server/db.py +1816 -0
  82. hoja_server/denials.py +18 -0
  83. hoja_server/deployment.py +28 -0
  84. hoja_server/email_adapter.py +94 -0
  85. hoja_server/google_login.py +217 -0
  86. hoja_server/google_oidc.py +342 -0
  87. hoja_server/identity_transfer.py +210 -0
  88. hoja_server/mcp_oauth.py +623 -0
  89. hoja_server/mcp_tools.py +579 -0
  90. hoja_server/meeting_policy.py +179 -0
  91. hoja_server/meetings.py +1332 -0
  92. hoja_server/meetings_api.py +909 -0
  93. hoja_server/model_providers.py +205 -0
  94. hoja_server/realtime.py +690 -0
  95. hoja_server/resolvers.py +1281 -0
  96. hoja_server/schema.graphql +477 -0
  97. hoja_server/schema_sdl.py +8 -0
  98. hoja_server/secrets_box.py +39 -0
  99. hoja_server/signup_config.py +292 -0
  100. hoja_server/signup_gate.py +393 -0
  101. hoja_server/signup_policy.py +97 -0
  102. hoja_server/signup_store.py +577 -0
  103. hoja_server/store.py +2179 -0
  104. hoja_server/sync.py +249 -0
  105. hoja_server/tenant_context.py +131 -0
  106. hoja_server/turnstile.py +81 -0
  107. hoja_server/writer.py +1262 -0
@@ -0,0 +1,623 @@
1
+ """OAuth-backed remote Hoja MCP server.
2
+
3
+ Authorization and tokens are persisted by the existing SQLAlchemy database. The
4
+ MCP SDK owns protocol serialization, metadata, DCR, PKCE verification, and HTTP
5
+ transport; this module owns Hoja identity binding and consent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import secrets
13
+ import time
14
+ from datetime import datetime, timedelta, timezone
15
+ from urllib.parse import urlencode
16
+
17
+ from mcp.server.auth.provider import (
18
+ AccessToken,
19
+ AuthorizationCode,
20
+ AuthorizationParams,
21
+ AuthorizeError,
22
+ OAuthAuthorizationServerProvider,
23
+ RefreshToken,
24
+ RegistrationError,
25
+ TokenError,
26
+ )
27
+ from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
28
+ from mcp.server.mcpserver import MCPServer
29
+ from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
30
+ from sqlalchemy import func, select, update
31
+ from starlette.requests import Request
32
+ from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
33
+
34
+ from hoja_server import db
35
+ from hoja_server import tenant_context
36
+ from hoja_server.denials import DENY_HINT
37
+ from hoja_server.mcp_tools import INSTRUCTIONS as MCP_INSTRUCTIONS
38
+
39
+ READ_SCOPE = "hoja:read"
40
+ WRITE_SCOPE = "hoja:write"
41
+ ALL_SCOPES = [READ_SCOPE, WRITE_SCOPE]
42
+ ACCESS_TTL = 900
43
+ REFRESH_TTL = 60 * 60 * 24 * 30
44
+ CODE_TTL = 300
45
+ MAX_REDIRECT_URIS = 5
46
+ MAX_REDIRECT_URI_LENGTH = 2048
47
+ MAX_CLIENT_NAME_LENGTH = 200
48
+ MAX_OAUTH_CLIENTS = 10_000
49
+ MAX_REGISTRATIONS_PER_HOUR = 100
50
+ _REGISTRATION_WINDOW_SECONDS = 3600
51
+
52
+
53
+ def _take_registration_slot(engine) -> None:
54
+ """Database-wide sliding-window limit on dynamic client registrations (shared across workers)."""
55
+ now = time.time()
56
+ key_hash = db.rate_limit_key_hash("global")
57
+ with engine.begin() as conn:
58
+ conn.execute(
59
+ db.rate_limit_event.delete().where(
60
+ db.rate_limit_event.c.bucket == "oauth_dcr",
61
+ db.rate_limit_event.c.created_at <= now - _REGISTRATION_WINDOW_SECONDS,
62
+ )
63
+ )
64
+ count = conn.execute(
65
+ select(func.count())
66
+ .select_from(db.rate_limit_event)
67
+ .where(db.rate_limit_event.c.bucket == "oauth_dcr")
68
+ ).scalar_one()
69
+ if count >= MAX_REGISTRATIONS_PER_HOUR:
70
+ raise RegistrationError(
71
+ "invalid_client_metadata", "Too many client registrations; try again later."
72
+ )
73
+ conn.execute(
74
+ db.rate_limit_event.insert().values(bucket="oauth_dcr", key_hash=key_hash, created_at=now)
75
+ )
76
+
77
+
78
+ def _now() -> datetime:
79
+ return datetime.now(timezone.utc)
80
+
81
+
82
+ def _iso(value: datetime) -> str:
83
+ return value.isoformat()
84
+
85
+
86
+ def _digest(raw: str) -> str:
87
+ return hashlib.sha256(raw.encode()).hexdigest()
88
+
89
+
90
+ def _secret() -> str:
91
+ return secrets.token_urlsafe(48)
92
+
93
+
94
+ class HojaOAuthProvider(OAuthAuthorizationServerProvider):
95
+ """SDK OAuth provider. Raw bearer values are never persisted."""
96
+
97
+ def __init__(self, engine, store, trusted_proxy: bool = False):
98
+ self.engine = engine
99
+ self.store = store
100
+ self.trusted_proxy = trusted_proxy
101
+ from hoja_server.writer import Writer
102
+
103
+ self.writer = Writer(engine)
104
+
105
+ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
106
+ with self.engine.connect() as conn:
107
+ raw = conn.execute(
108
+ select(db.oauth_client.c.client_json).where(
109
+ db.oauth_client.c.client_id == client_id
110
+ )
111
+ ).scalar_one_or_none()
112
+ return OAuthClientInformationFull.model_validate_json(raw) if raw else None
113
+
114
+ async def register_client(self, client_info: OAuthClientInformationFull) -> None:
115
+ if not self.trusted_proxy:
116
+ raise RegistrationError(
117
+ "invalid_client_metadata", "MCP OAuth is not enabled on this deployment."
118
+ )
119
+ if not client_info.redirect_uris:
120
+ raise RegistrationError(
121
+ "invalid_redirect_uri", "At least one redirect URI is required."
122
+ )
123
+ if len(client_info.redirect_uris) > MAX_REDIRECT_URIS:
124
+ raise RegistrationError("invalid_redirect_uri", "Too many redirect URIs.")
125
+ if client_info.client_name and len(client_info.client_name) > MAX_CLIENT_NAME_LENGTH:
126
+ raise RegistrationError("invalid_client_metadata", "client_name is too long.")
127
+ if client_info.token_endpoint_auth_method not in (None, "none"):
128
+ raise RegistrationError(
129
+ "invalid_client_metadata", "Only public OAuth clients are supported."
130
+ )
131
+ for uri in client_info.redirect_uris:
132
+ text = str(uri)
133
+ if len(text) > MAX_REDIRECT_URI_LENGTH:
134
+ raise RegistrationError("invalid_redirect_uri", "Redirect URI is too long.")
135
+ if uri.scheme != "https" and not _is_loopback_uri(text):
136
+ raise RegistrationError(
137
+ "invalid_redirect_uri", "Redirect URI must use HTTPS or native loopback."
138
+ )
139
+ if uri.username or uri.password or uri.fragment:
140
+ raise RegistrationError(
141
+ "invalid_redirect_uri", "Redirect URI contains a forbidden component."
142
+ )
143
+ if client_info.scope:
144
+ requested = set(client_info.scope.split())
145
+ if not requested.issubset(ALL_SCOPES):
146
+ raise RegistrationError("invalid_client_metadata", "Unsupported OAuth scope.")
147
+ clean = client_info.model_copy(
148
+ update={"client_secret": None, "token_endpoint_auth_method": "none"}
149
+ )
150
+ _take_registration_slot(self.engine)
151
+ with self.engine.begin() as conn:
152
+ total = conn.execute(select(func.count()).select_from(db.oauth_client)).scalar_one()
153
+ if total >= MAX_OAUTH_CLIENTS:
154
+ raise RegistrationError(
155
+ "invalid_client_metadata", "Client registration limit reached."
156
+ )
157
+ conn.execute(
158
+ db.oauth_client.insert().values(
159
+ client_id=clean.client_id,
160
+ client_json=clean.model_dump_json(),
161
+ created_at=db.now_iso(),
162
+ )
163
+ )
164
+
165
+ async def authorize(
166
+ self, client: OAuthClientInformationFull, params: AuthorizationParams
167
+ ) -> str:
168
+ if not self.trusted_proxy:
169
+ raise AuthorizeError(
170
+ "temporarily_unavailable", "MCP OAuth is not enabled on this deployment."
171
+ )
172
+ if params.resource and self._resource_url() != params.resource.rstrip("/"):
173
+ raise AuthorizeError("invalid_target", "Unknown resource.")
174
+ scopes = params.scopes or [READ_SCOPE]
175
+ if not set(scopes).issubset(ALL_SCOPES):
176
+ raise AuthorizeError("invalid_scope", "Unsupported scope.")
177
+ # OAuth SDK validates exact registered redirect and requires S256 before this hook.
178
+ pending_id, csrf_token = _secret(), _secret()
179
+ expires = _now() + timedelta(minutes=5)
180
+ with self.engine.begin() as conn:
181
+ conn.execute(db.oauth_pending.insert().values(
182
+ pending_hash=_digest(pending_id), client_id=client.client_id,
183
+ scopes=json.dumps(scopes), code_challenge=params.code_challenge,
184
+ redirect_uri=str(params.redirect_uri),
185
+ redirect_explicit=params.redirect_uri_provided_explicitly,
186
+ state=params.state, csrf_token=csrf_token, resource=params.resource,
187
+ expires_at=_iso(expires), consumed_at=None,
188
+ ))
189
+ return f"/oauth/consent?{urlencode({'pending': pending_id})}"
190
+
191
+ async def load_authorization_code(self, client, authorization_code: str):
192
+ with self.engine.connect() as conn:
193
+ row = (
194
+ conn.execute(
195
+ select(db.oauth_authorization).where(
196
+ db.oauth_authorization.c.code_hash == _digest(authorization_code),
197
+ db.oauth_authorization.c.client_id == client.client_id,
198
+ db.oauth_authorization.c.consumed_at.is_(None),
199
+ )
200
+ )
201
+ .mappings()
202
+ .first()
203
+ )
204
+ if not row or row["expires_at"] <= _iso(_now()):
205
+ return None
206
+ return AuthorizationCode(
207
+ code=authorization_code,
208
+ scopes=json.loads(row["scopes"]),
209
+ expires_at=_parse_dt(row["expires_at"]).timestamp(),
210
+ client_id=row["client_id"],
211
+ code_challenge=row["code_challenge"],
212
+ redirect_uri=row["redirect_uri"],
213
+ redirect_uri_provided_explicitly=True,
214
+ subject=json.dumps({"user_id": row["user_id"], "org_id": row["organization_id"]}),
215
+ resource=row["resource"],
216
+ )
217
+
218
+ async def exchange_authorization_code(
219
+ self, client, authorization_code: AuthorizationCode
220
+ ) -> OAuthToken:
221
+ now = _now()
222
+ code_hash = _digest(authorization_code.code)
223
+ with self.engine.begin() as conn:
224
+ row = (
225
+ conn.execute(
226
+ select(db.oauth_authorization)
227
+ .where(
228
+ db.oauth_authorization.c.code_hash == code_hash,
229
+ db.oauth_authorization.c.client_id == client.client_id,
230
+ db.oauth_authorization.c.consumed_at.is_(None),
231
+ db.oauth_authorization.c.expires_at > _iso(now),
232
+ )
233
+ .with_for_update()
234
+ )
235
+ .mappings()
236
+ .first()
237
+ )
238
+ if not row:
239
+ raise TokenError("invalid_grant", "Authorization code is invalid or already used.")
240
+ consumed = conn.execute(
241
+ update(db.oauth_authorization)
242
+ .where(
243
+ db.oauth_authorization.c.code_hash == code_hash,
244
+ db.oauth_authorization.c.consumed_at.is_(None),
245
+ )
246
+ .values(consumed_at=_iso(now))
247
+ )
248
+ if consumed.rowcount != 1:
249
+ raise TokenError("invalid_grant", "Authorization code is invalid or already used.")
250
+ return self._mint_pair(
251
+ conn,
252
+ row["client_id"],
253
+ row["user_id"],
254
+ row["organization_id"],
255
+ json.loads(row["scopes"]),
256
+ authorization_code.resource,
257
+ )
258
+
259
+ async def load_refresh_token(self, client, refresh_token: str):
260
+ with self.engine.begin() as conn:
261
+ row = (
262
+ conn.execute(
263
+ select(db.oauth_refresh_token).where(
264
+ db.oauth_refresh_token.c.token_hash == _digest(refresh_token),
265
+ db.oauth_refresh_token.c.client_id == client.client_id,
266
+ )
267
+ )
268
+ .mappings()
269
+ .first()
270
+ )
271
+ if row and row["revoked_at"] is not None:
272
+ # The SDK rejects revoked tokens before exchange_refresh_token runs, so
273
+ # reuse of a rotated/revoked token must be handled here.
274
+ now = _iso(_now())
275
+ for target in (db.oauth_access_token, db.oauth_refresh_token):
276
+ conn.execute(
277
+ update(target)
278
+ .where(target.c.grant_id == row["grant_id"], target.c.revoked_at.is_(None))
279
+ .values(revoked_at=now)
280
+ )
281
+ return None
282
+ if not row or row["expires_at"] <= _iso(_now()):
283
+ return None
284
+ subject = json.dumps({"user_id": row["user_id"], "org_id": row["organization_id"], "grant_id": row["grant_id"]})
285
+ return RefreshToken(
286
+ token=refresh_token,
287
+ client_id=row["client_id"],
288
+ scopes=json.loads(row["scopes"]),
289
+ expires_at=int(_parse_dt(row["expires_at"]).timestamp()),
290
+ resource=row["resource"],
291
+ subject=subject,
292
+ )
293
+
294
+ async def exchange_refresh_token(
295
+ self, client, refresh_token: RefreshToken, scopes: list[str]
296
+ ) -> OAuthToken:
297
+ original = set(refresh_token.scopes)
298
+ requested = set(scopes or refresh_token.scopes)
299
+ if not requested.issubset(original):
300
+ raise TokenError("invalid_scope", "Refresh cannot increase granted scopes.")
301
+ replay_detected = False
302
+ token = None
303
+ with self.engine.begin() as conn:
304
+ existing_grant = conn.execute(select(db.oauth_refresh_token.c.grant_id).where(
305
+ db.oauth_refresh_token.c.token_hash == _digest(refresh_token.token),
306
+ db.oauth_refresh_token.c.client_id == client.client_id,
307
+ )).scalar_one_or_none()
308
+ active_grant = conn.execute(select(db.oauth_refresh_token.c.grant_id).where(
309
+ db.oauth_refresh_token.c.token_hash == _digest(refresh_token.token),
310
+ db.oauth_refresh_token.c.client_id == client.client_id,
311
+ db.oauth_refresh_token.c.revoked_at.is_(None),
312
+ db.oauth_refresh_token.c.expires_at > _iso(_now()),
313
+ )).scalar_one_or_none()
314
+ result = conn.execute(
315
+ update(db.oauth_refresh_token)
316
+ .where(
317
+ db.oauth_refresh_token.c.token_hash == _digest(refresh_token.token),
318
+ db.oauth_refresh_token.c.client_id == client.client_id,
319
+ db.oauth_refresh_token.c.revoked_at.is_(None),
320
+ db.oauth_refresh_token.c.expires_at > _iso(_now()),
321
+ )
322
+ .values(revoked_at=_iso(_now()))
323
+ )
324
+ if active_grant is None and existing_grant:
325
+ now = _iso(_now())
326
+ conn.execute(update(db.oauth_access_token).where(
327
+ db.oauth_access_token.c.grant_id == existing_grant,
328
+ db.oauth_access_token.c.revoked_at.is_(None),
329
+ ).values(revoked_at=now))
330
+ conn.execute(update(db.oauth_refresh_token).where(
331
+ db.oauth_refresh_token.c.grant_id == existing_grant,
332
+ db.oauth_refresh_token.c.revoked_at.is_(None),
333
+ ).values(revoked_at=now))
334
+ replay_detected = True
335
+ elif result.rowcount == 1:
336
+ claims = json.loads(refresh_token.subject or "{}")
337
+ token = self._mint_pair(
338
+ conn,
339
+ client.client_id,
340
+ claims["user_id"],
341
+ claims["org_id"],
342
+ sorted(requested),
343
+ refresh_token.resource,
344
+ grant_id=claims["grant_id"],
345
+ )
346
+ if replay_detected or token is None:
347
+ raise TokenError("invalid_grant", "Refresh token is invalid or already used.")
348
+ return token
349
+
350
+ async def load_access_token(self, token: str):
351
+ # The bearer is looked up before any tenant is known (system context);
352
+ # a valid token then scopes the rest of the MCP request to its org.
353
+ with tenant_context.system(), self.engine.connect() as conn:
354
+ row = (
355
+ conn.execute(
356
+ select(db.oauth_access_token).where(
357
+ db.oauth_access_token.c.token_hash == _digest(token),
358
+ db.oauth_access_token.c.revoked_at.is_(None),
359
+ )
360
+ )
361
+ .mappings()
362
+ .first()
363
+ )
364
+ if not row or row["expires_at"] <= _iso(_now()):
365
+ return None
366
+ tenant_context.bind(row["organization_id"])
367
+ subject = json.dumps({"user_id": row["user_id"], "org_id": row["organization_id"]})
368
+ return AccessToken(
369
+ token=token,
370
+ client_id=row["client_id"],
371
+ scopes=json.loads(row["scopes"]),
372
+ expires_at=int(_parse_dt(row["expires_at"]).timestamp()),
373
+ resource=row["resource"],
374
+ subject=subject,
375
+ )
376
+
377
+ async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
378
+ table = db.oauth_access_token if isinstance(token, AccessToken) else db.oauth_refresh_token
379
+ grant_id = None
380
+ with self.engine.begin() as conn:
381
+ grant_id = conn.execute(
382
+ select(table.c.grant_id).where(table.c.token_hash == _digest(token.token))
383
+ ).scalar_one_or_none()
384
+ if grant_id:
385
+ for target in (db.oauth_access_token, db.oauth_refresh_token):
386
+ conn.execute(
387
+ update(target)
388
+ .where(target.c.grant_id == grant_id, target.c.revoked_at.is_(None))
389
+ .values(revoked_at=_iso(_now()))
390
+ )
391
+
392
+ def _mint_pair(
393
+ self,
394
+ conn,
395
+ client_id: str,
396
+ user_id: str,
397
+ org_id: str,
398
+ scopes: list[str],
399
+ resource: str | None,
400
+ *,
401
+ grant_id: str | None = None,
402
+ ) -> OAuthToken:
403
+ access, refresh = _secret(), _secret()
404
+ grant_id = grant_id or _secret()
405
+ access_expires, refresh_expires = (
406
+ _now() + timedelta(seconds=ACCESS_TTL),
407
+ _now() + timedelta(seconds=REFRESH_TTL),
408
+ )
409
+ common = dict(
410
+ client_id=client_id,
411
+ user_id=user_id,
412
+ organization_id=org_id,
413
+ scopes=json.dumps(scopes),
414
+ grant_id=grant_id,
415
+ resource=resource,
416
+ )
417
+ conn.execute(
418
+ db.oauth_access_token.insert().values(
419
+ token_hash=_digest(access), **common, expires_at=_iso(access_expires)
420
+ )
421
+ )
422
+ conn.execute(
423
+ db.oauth_refresh_token.insert().values(
424
+ token_hash=_digest(refresh), **common, expires_at=_iso(refresh_expires)
425
+ )
426
+ )
427
+ return OAuthToken(
428
+ access_token=access,
429
+ refresh_token=refresh,
430
+ expires_in=ACCESS_TTL,
431
+ scope=" ".join(scopes),
432
+ )
433
+
434
+ def _resource_url(self) -> str:
435
+ return _public_origin(self.trusted_proxy) + "/mcp"
436
+
437
+ async def finish_consent(self, request: Request, pending_id: str, accepted: bool, csrf_token: str):
438
+ auth = request.scope.get("hoja_auth")
439
+ now = _iso(_now())
440
+ with self.engine.begin() as conn:
441
+ row = conn.execute(select(db.oauth_pending).where(
442
+ db.oauth_pending.c.pending_hash == _digest(pending_id),
443
+ db.oauth_pending.c.consumed_at.is_(None),
444
+ db.oauth_pending.c.expires_at > now,
445
+ )).mappings().first()
446
+ if not auth or not row or not secrets.compare_digest(csrf_token, row["csrf_token"]):
447
+ return JSONResponse({"error": "Authorization session expired; restart from the MCP client."}, status_code=400)
448
+ if row["user_id"] and (row["user_id"] != auth.get("user_id") or row["organization_id"] != auth.get("org_id")):
449
+ return JSONResponse({"error": "Authorization session expired; restart from the MCP client."}, status_code=400)
450
+ changed = conn.execute(update(db.oauth_pending).where(
451
+ db.oauth_pending.c.pending_hash == row["pending_hash"],
452
+ db.oauth_pending.c.consumed_at.is_(None), db.oauth_pending.c.expires_at > now,
453
+ ).values(consumed_at=now, user_id=auth["user_id"], organization_id=auth["org_id"])).rowcount
454
+ if changed != 1:
455
+ return JSONResponse({"error": "Authorization request already used."}, status_code=400)
456
+ if not accepted:
457
+ return JSONResponse({"error": "Authorization was denied."}, status_code=403)
458
+ code = _secret()
459
+ conn.execute(db.oauth_authorization.insert().values(
460
+ code_hash=_digest(code), client_id=row["client_id"], user_id=auth["user_id"],
461
+ organization_id=auth["org_id"], scopes=row["scopes"],
462
+ code_challenge=row["code_challenge"], redirect_uri=row["redirect_uri"],
463
+ resource=row["resource"], expires_at=_iso(_now()+timedelta(seconds=CODE_TTL)), consumed_at=None,
464
+ ))
465
+ query = {"code": code}
466
+ if row["state"]:
467
+ query["state"] = row["state"]
468
+ return RedirectResponse(row["redirect_uri"] + ("&" if "?" in row["redirect_uri"] else "?") + urlencode(query))
469
+
470
+
471
+ def _parse_dt(value: str) -> datetime:
472
+ return datetime.fromisoformat(value)
473
+
474
+
475
+ def _is_loopback_uri(uri: str) -> bool:
476
+ import ipaddress
477
+ from urllib.parse import urlparse
478
+
479
+ parsed = urlparse(uri)
480
+ if parsed.scheme != "http" or parsed.hostname is None:
481
+ return False
482
+ if parsed.hostname == "localhost":
483
+ return True
484
+ try:
485
+ return ipaddress.ip_address(parsed.hostname).is_loopback
486
+ except ValueError:
487
+ return False
488
+
489
+
490
+ def _public_origin(trust_proxy: bool, public_host: str | None = None) -> str:
491
+ # Proxy headers are only honored when operator explicitly enables trust.
492
+ import os
493
+
494
+ if not trust_proxy:
495
+ raise RuntimeError("Hoja MCP OAuth requires explicit trusted proxy configuration.")
496
+ host = (public_host if public_host is not None else os.environ.get("HOJA_TRUSTED_PUBLIC_HOST", "")).strip()
497
+ scheme = os.environ.get("HOJA_TRUSTED_PUBLIC_SCHEME", "https").strip().lower()
498
+ if not host or "/" in host or "@" in host or scheme != "https":
499
+ raise RuntimeError("Trusted proxy public origin is not configured safely.")
500
+ return f"https://{host}"
501
+
502
+
503
+ def build_server(provider: HojaOAuthProvider, public_host: str | None = None) -> MCPServer:
504
+ from hoja import __version__
505
+
506
+ # Keep ordinary Hoja API/test startup independent of OAuth deployment config.
507
+ # OAuth metadata is only served when trusted proxy origin config is supplied.
508
+ public_origin = (
509
+ _public_origin(provider.trusted_proxy, public_host) if provider.trusted_proxy else "https://mcp.invalid"
510
+ )
511
+ issuer = public_origin + "/mcp"
512
+ server = MCPServer(
513
+ name="hoja",
514
+ instructions=MCP_INSTRUCTIONS,
515
+ version=__version__,
516
+ auth_server_provider=provider,
517
+ auth=AuthSettings(
518
+ issuer_url=issuer,
519
+ resource_server_url=issuer,
520
+ validate_token_resource=True,
521
+ required_scopes=[READ_SCOPE],
522
+ client_registration_options=ClientRegistrationOptions(
523
+ enabled=True, valid_scopes=ALL_SCOPES, default_scopes=[READ_SCOPE]
524
+ ),
525
+ revocation_options=RevocationOptions(enabled=True),
526
+ ),
527
+ )
528
+
529
+ from mcp.server.mcpserver.exceptions import ToolError
530
+
531
+ from hoja_server.mcp_tools import build_tools
532
+ from hoja_server.resolvers import build_schema
533
+
534
+ def principal(write: bool) -> dict:
535
+ from mcp.server.auth.middleware.auth_context import get_access_token
536
+
537
+ access = get_access_token()
538
+ if access is None or not access.subject:
539
+ raise ToolError("OAuth authentication is required. " + DENY_HINT)
540
+ granted = set(access.scopes)
541
+ if write and WRITE_SCOPE not in granted:
542
+ raise ToolError(
543
+ f"This connection is read-only: the {WRITE_SCOPE} scope was not granted, so nothing was changed. "
544
+ "Ask the user to reconnect the Hoja MCP with write access. " + DENY_HINT
545
+ )
546
+ if READ_SCOPE not in granted:
547
+ raise ToolError(f"OAuth scope {READ_SCOPE} is required. " + DENY_HINT)
548
+ try:
549
+ identity = json.loads(access.subject)
550
+ user_id, org_id = identity["user_id"], identity["org_id"]
551
+ except (ValueError, KeyError, TypeError) as exc:
552
+ raise ToolError("OAuth identity is invalid. " + DENY_HINT) from exc
553
+ if not provider.store.membership_for_profile(org_id, user_id):
554
+ raise ToolError("Active organization membership is required. " + DENY_HINT)
555
+ return {
556
+ "store": provider.store,
557
+ "writer": provider.writer,
558
+ "org_id": org_id,
559
+ "user_id": user_id,
560
+ "identity_id": None,
561
+ "browser_session": False,
562
+ # Read-only grants must not trigger resolver-side reconciliation writes.
563
+ "read_only": WRITE_SCOPE not in granted,
564
+ }
565
+
566
+ for tool in build_tools(build_schema(), principal):
567
+ server._tool_manager._tools[tool.name] = tool
568
+
569
+ @server.custom_route("/oauth/consent", methods=["GET", "POST"])
570
+ async def consent(request: Request):
571
+ pending_id = request.query_params.get("pending")
572
+ auth = request.scope.get("hoja_auth")
573
+ if not auth:
574
+ response = RedirectResponse(
575
+ "/auth/login?next=" + request.url.path + "%3Fpending%3D" + (pending_id or "")
576
+ )
577
+ return response
578
+ if request.method == "POST":
579
+ form = await request.form()
580
+ return await provider.finish_consent(
581
+ request,
582
+ pending_id or "",
583
+ form.get("decision") == "allow",
584
+ str(form.get("csrf_token", "")),
585
+ )
586
+ with provider.engine.connect() as conn:
587
+ pending = conn.execute(select(db.oauth_pending).where(
588
+ db.oauth_pending.c.pending_hash == _digest(pending_id or ""),
589
+ db.oauth_pending.c.consumed_at.is_(None), db.oauth_pending.c.expires_at > _iso(_now()),
590
+ )).mappings().first()
591
+ if pending and request.method == "GET":
592
+ with provider.engine.begin() as conn:
593
+ conn.execute(update(db.oauth_pending).where(
594
+ db.oauth_pending.c.pending_hash == pending["pending_hash"],
595
+ db.oauth_pending.c.consumed_at.is_(None),
596
+ db.oauth_pending.c.user_id.is_(None),
597
+ ).values(user_id=auth["user_id"], organization_id=auth["org_id"]))
598
+ pending = conn.execute(select(db.oauth_pending).where(
599
+ db.oauth_pending.c.pending_hash == pending["pending_hash"]
600
+ )).mappings().first()
601
+ client = await provider.get_client(pending["client_id"]) if pending else None
602
+ if not pending or not client or pending["user_id"] != auth["user_id"] or pending["organization_id"] != auth["org_id"]:
603
+ return JSONResponse({"error": "Authorization request expired."}, status_code=400)
604
+ return HTMLResponse(
605
+ "<!doctype html><meta charset=utf-8><title>Authorize Hoja MCP</title>"
606
+ "<h1>Authorize MCP client</h1><p>Client: "
607
+ + _escape(client.client_name or client.client_id)
608
+ + "</p>"
609
+ "<p>Workspace: " + _escape(auth.get("org_name", auth["org_id"])) + "</p>"
610
+ "<p>Requested access: " + _escape(", ".join(pending["scopes"])) + "</p>"
611
+ "<form method=post><input type=hidden name=csrf_token value=\""
612
+ + _escape(pending["csrf_token"])
613
+ + "\"><button name=decision value=allow>Allow</button>"
614
+ "<button name=decision value=deny>Deny</button></form>"
615
+ )
616
+
617
+ return server
618
+
619
+
620
+ def _escape(value: str) -> str:
621
+ import html
622
+
623
+ return html.escape(value, quote=True)