lambda-erp 1.1.2__tar.gz → 1.1.4__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.
Files changed (113) hide show
  1. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/PKG-INFO +1 -1
  2. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/auth.py +15 -5
  3. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/main.py +3 -0
  4. lambda_erp-1.1.4/api/mcp_oauth.py +423 -0
  5. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/oauth.py +5 -1
  6. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/mcp.py +70 -10
  7. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/frontend/src/api/client.ts +9 -5
  8. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/database.py +24 -0
  9. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/pyproject.toml +1 -1
  10. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/.gitignore +0 -0
  11. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/LICENSE +0 -0
  12. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/README.md +0 -0
  13. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/__init__.py +0 -0
  14. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/attachments.py +0 -0
  15. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/bootstrap.py +0 -0
  16. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/chat.py +0 -0
  17. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/chat_links.py +0 -0
  18. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/demo_limits.py +0 -0
  19. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/deps.py +0 -0
  20. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/errors.py +0 -0
  21. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/list_values.py +0 -0
  22. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/pdf.py +0 -0
  23. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/pdf_contract.py +0 -0
  24. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/pdf_exports.py +0 -0
  25. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/pdf_profiles.py +0 -0
  26. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/providers.py +0 -0
  27. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/remarks_md.py +0 -0
  28. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/__init__.py +0 -0
  29. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/accounting.py +0 -0
  30. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/actions.py +0 -0
  31. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/admin.py +0 -0
  32. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/analytics.py +0 -0
  33. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/availability.py +0 -0
  34. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/bank_reconciliation.py +0 -0
  35. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/bank_statements.py +0 -0
  36. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/chat_api.py +0 -0
  37. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/documents.py +0 -0
  38. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/masters.py +0 -0
  39. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/proposals.py +0 -0
  40. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/reports.py +0 -0
  41. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/routers/setup.py +0 -0
  42. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/services.py +0 -0
  43. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/templates/_proposal_totals.html +0 -0
  44. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/templates/_record_base.html +0 -0
  45. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/templates/document.html +0 -0
  46. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/templates/proposal.html +0 -0
  47. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/templates/record.html +0 -0
  48. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/time_filters.py +0 -0
  49. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/api/tool_permissions.py +0 -0
  50. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/docs/agents/README.md +0 -0
  51. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/frontend/LICENSE +0 -0
  52. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/frontend/README.md +0 -0
  53. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/__init__.py +0 -0
  54. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/__init__.py +0 -0
  55. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/bank_account.py +0 -0
  56. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  57. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/bank_statement_import.py +0 -0
  58. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/bank_transaction.py +0 -0
  59. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/budget.py +0 -0
  60. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/camt.py +0 -0
  61. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  62. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/general_ledger.py +0 -0
  63. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/journal_entry.py +0 -0
  64. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/payment_entry.py +0 -0
  65. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/pos_invoice.py +0 -0
  66. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/purchase_invoice.py +0 -0
  67. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/revaluation.py +0 -0
  68. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/sales_invoice.py +0 -0
  69. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/settlement.py +0 -0
  70. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/setup/__init__.py +0 -0
  71. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/setup/engine.py +0 -0
  72. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/setup/pack.py +0 -0
  73. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  74. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  75. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  76. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  77. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  78. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  79. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/setup/profiles.py +0 -0
  80. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/setup/spine.py +0 -0
  81. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/accounting/subscription.py +0 -0
  82. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/assets/__init__.py +0 -0
  83. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/assets/asset.py +0 -0
  84. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/assets/lifecycle.py +0 -0
  85. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/assets/reservation.py +0 -0
  86. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/buying/__init__.py +0 -0
  87. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/buying/purchase_order.py +0 -0
  88. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/controllers/__init__.py +0 -0
  89. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/controllers/currency.py +0 -0
  90. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/controllers/defaults.py +0 -0
  91. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/controllers/item_price.py +0 -0
  92. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/controllers/item_prices.py +0 -0
  93. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/controllers/pricing_rule.py +0 -0
  94. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  95. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/exceptions.py +0 -0
  96. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/hooks.py +0 -0
  97. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/master_integrity.py +0 -0
  98. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/model.py +0 -0
  99. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/selling/__init__.py +0 -0
  100. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/selling/proposal.py +0 -0
  101. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/selling/quotation.py +0 -0
  102. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/selling/sales_order.py +0 -0
  103. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/simulation.py +0 -0
  104. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/stock/__init__.py +0 -0
  105. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/stock/delivery_note.py +0 -0
  106. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/stock/purchase_receipt.py +0 -0
  107. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/stock/stock_entry.py +0 -0
  108. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/stock/stock_ledger.py +0 -0
  109. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/timestamps.py +0 -0
  110. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/utils.py +0 -0
  111. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/validation.py +0 -0
  112. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/lambda_erp/workflow.py +0 -0
  113. {lambda_erp-1.1.2 → lambda_erp-1.1.4}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 1.1.2
3
+ Version: 1.1.4
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -9,6 +9,7 @@ import uuid
9
9
  import secrets
10
10
  import hashlib
11
11
  from datetime import datetime, timedelta, timezone
12
+ from typing import Literal
12
13
 
13
14
  import bcrypt
14
15
  from jose import jwt, JWTError
@@ -259,12 +260,18 @@ def refresh_auth_principal(principal: dict | None) -> dict | None:
259
260
  else owner["role"]
260
261
  )
261
262
  owner["role"] = effective_role
262
- return make_auth_principal(
263
+ scope_role = principal.get("oauth_scope_role")
264
+ if scope_role:
265
+ owner["role"] = min((owner["role"], scope_role), key=_role_rank)
266
+ refreshed = make_auth_principal(
263
267
  owner,
264
268
  API_KEY_CREDENTIAL,
265
269
  name=principal.get("name") or key.get("session_owner") or owner["name"],
266
270
  api_key_id=key["id"],
267
271
  )
272
+ if scope_role:
273
+ refreshed["oauth_scope_role"] = scope_role
274
+ return refreshed
268
275
 
269
276
  user_id = principal.get("user_id") or principal.get("name")
270
277
  user = db.get_value(
@@ -981,6 +988,7 @@ class ApiKeyCreate(BaseModel):
981
988
  # Optional role CAP. Defaults to the creator's own role; may only be equal
982
989
  # or lower — a key can never out-rank its owner.
983
990
  role: str | None = None
991
+ app_type: Literal['claude_code', 'codex', 'other'] = 'other'
984
992
 
985
993
 
986
994
  def _serialize_api_key(row: dict) -> dict:
@@ -997,6 +1005,7 @@ def _serialize_api_key(row: dict) -> dict:
997
1005
  "created_at": row.get("created_at"),
998
1006
  "last_used_at": row.get("last_used_at"),
999
1007
  "revoked": bool(row.get("revoked")),
1008
+ "app_type": row.get("app_type") or "other",
1000
1009
  }
1001
1010
 
1002
1011
 
@@ -1024,7 +1033,7 @@ def list_api_keys(user: dict = Depends(require_interactive_user)):
1024
1033
  db = get_db()
1025
1034
  base = (
1026
1035
  'SELECT k.id, k.name, k.owner, k.role, k.key_prefix, k.created_at, '
1027
- 'k.last_used_at, k.revoked, u.full_name AS owner_full_name, u.email AS owner_email '
1036
+ 'k.last_used_at, k.revoked, k.app_type, u.full_name AS owner_full_name, u.email AS owner_email '
1028
1037
  'FROM "Api Key" k LEFT JOIN "User" u ON u.name = k.owner '
1029
1038
  )
1030
1039
  if user["role"] == "admin":
@@ -1068,15 +1077,16 @@ def create_api_key(data: ApiKeyCreate, user: dict = Depends(require_interactive_
1068
1077
  created_at = now()
1069
1078
  db.sql(
1070
1079
  'INSERT INTO "Api Key" '
1071
- '(id, name, owner, key_hash, key_prefix, role, session_owner, created_at, last_used_at, revoked) '
1072
- 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)',
1073
- [key_id, name, user["name"], key_hash, key_prefix, role, f"api:{user['name']}", created_at, None],
1080
+ '(id, name, owner, key_hash, key_prefix, role, session_owner, created_at, last_used_at, revoked, app_type) '
1081
+ 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)',
1082
+ [key_id, name, user["name"], key_hash, key_prefix, role, f"api:{user['name']}", created_at, None, data.app_type],
1074
1083
  )
1075
1084
  db.conn.commit()
1076
1085
  row = {
1077
1086
  "id": key_id, "name": name, "owner": user["name"], "role": role, "key_prefix": key_prefix,
1078
1087
  "created_at": created_at, "last_used_at": None, "revoked": 0,
1079
1088
  "owner_full_name": user.get("full_name"), "owner_email": user.get("email"),
1089
+ "app_type": data.app_type,
1080
1090
  }
1081
1091
  result = _serialize_api_key(row)
1082
1092
  result["is_mine"] = True
@@ -17,6 +17,7 @@ from lambda_erp.database import setup
17
17
  from api.errors import register_exception_handlers
18
18
  from api.auth import router as auth_router, COOKIE_NAME, decode_token
19
19
  from api.oauth import router as oauth_router
20
+ from api.mcp_oauth import router as mcp_oauth_router, OAuthError, oauth_error_handler
20
21
  from api.attachments import router as attachments_router
21
22
  from api.chat import chat_websocket, router as chat_router
22
23
  from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, bank_statements, analytics, accounting, proposals, chat_api, mcp, availability, actions
@@ -101,10 +102,12 @@ app.add_middleware(
101
102
 
102
103
  # Exception handlers
103
104
  register_exception_handlers(app)
105
+ app.add_exception_handler(OAuthError, oauth_error_handler)
104
106
 
105
107
  # Routers
106
108
  app.include_router(auth_router, prefix="/api")
107
109
  app.include_router(oauth_router, prefix="/api")
110
+ app.include_router(mcp_oauth_router)
108
111
  app.include_router(attachments_router, prefix="/api")
109
112
  app.include_router(documents.router, prefix="/api")
110
113
  app.include_router(proposals.router, prefix="/api")
@@ -0,0 +1,423 @@
1
+ """OAuth authorization-code + S256 PKCE for remote MCP connectors.
2
+
3
+ Dynamic registration supports public and confidential clients; no client URLs
4
+ are fetched. Grants reuse the per-user Api Key role/revocation model, but have
5
+ no API-key secret. Opaque, hashed OAuth tokens are valid only at this MCP resource.
6
+ """
7
+ import base64
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ import os
12
+ import re
13
+ import secrets
14
+ import time
15
+ import uuid
16
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit, unquote
17
+
18
+ from fastapi import APIRouter, Depends, HTTPException, Request
19
+ from fastapi.responses import JSONResponse, RedirectResponse
20
+ from jose import jwt, JWTError
21
+ from pydantic import BaseModel
22
+
23
+ from api.auth import (ALGORITHM, SECRET_KEY, API_KEY_CREDENTIAL, ROLE_HIERARCHY,
24
+ _setting_enabled, require_interactive_user,
25
+ refresh_auth_principal)
26
+ from lambda_erp.database import get_db
27
+ from lambda_erp.utils import now
28
+
29
+ router = APIRouter(tags=['mcp-oauth'])
30
+ SCOPES = {'erp:read': 'viewer', 'erp:write': 'manager', 'erp:admin': 'admin'}
31
+ ACCESS_TTL = 3600
32
+ REFRESH_TTL = 30 * 86400
33
+ RETURN_COOKIE = 'lambda_erp_mcp_return'
34
+ NO_STORE = {'Cache-Control': 'no-store', 'Pragma': 'no-cache'}
35
+
36
+
37
+ class OAuthError(Exception):
38
+ def __init__(self, error, description, status=400):
39
+ self.error, self.description, self.status = error, description, status
40
+
41
+
42
+ async def oauth_error_handler(request, exc):
43
+ return JSONResponse({'error': exc.error, 'error_description': exc.description},
44
+ status_code=exc.status, headers=NO_STORE)
45
+
46
+
47
+ def digest(value):
48
+ return hashlib.sha256(value.encode()).hexdigest()
49
+
50
+
51
+ def issuer(request):
52
+ # Set on reverse-proxied deployments if the external origin differs from
53
+ # Request.base_url. Never derive OAuth metadata from forwarded host headers.
54
+ return os.environ.get('MCP_PUBLIC_URL', str(request.base_url)).rstrip('/')
55
+
56
+
57
+ def resource_url(request):
58
+ return issuer(request) + '/api/mcp'
59
+
60
+
61
+ def challenge_header(request):
62
+ return {'WWW-Authenticate': f'Bearer resource_metadata="{issuer(request)}/.well-known/oauth-protected-resource/api/mcp"'}
63
+
64
+
65
+ def _enabled():
66
+ if not _setting_enabled(get_db(), 'rest_api_enabled'):
67
+ raise OAuthError('access_denied', 'An administrator must enable REST API / MCP access first.', 403)
68
+
69
+
70
+ def _client(client_id):
71
+ rows = get_db().sql('SELECT * FROM "MCP OAuth Client" WHERE id = ?', [client_id])
72
+ if not rows:
73
+ raise OAuthError('invalid_client', 'Unknown client. Add the connector again.', 401)
74
+ return rows[0]
75
+
76
+
77
+ def _validate_redirect(uri):
78
+ if not isinstance(uri, str) or len(uri) > 2048 or re.search(r'[\s\\*]', uri):
79
+ raise OAuthError('invalid_redirect_uri', 'Invalid redirect URI.')
80
+ try:
81
+ parts = urlsplit(uri)
82
+ parts.port # Validate the port as well as the host.
83
+ except ValueError:
84
+ raise OAuthError('invalid_redirect_uri', 'Invalid redirect URI.')
85
+ if (not parts.hostname or parts.username or parts.password or parts.fragment
86
+ or parts.scheme not in {'https', 'http'}
87
+ or (parts.scheme == 'http' and parts.hostname not in {'127.0.0.1', '[::1]', '::1', 'localhost'})):
88
+ raise OAuthError('invalid_redirect_uri', 'Use HTTPS, or HTTP on a loopback address.')
89
+ return parts
90
+
91
+
92
+ def _redirect_matches(uri, registered):
93
+ candidate = _validate_redirect(uri)
94
+ for allowed in registered:
95
+ if uri == allowed:
96
+ return True
97
+ parts = urlsplit(allowed)
98
+ # RFC 8252 native apps choose an ephemeral loopback listener port.
99
+ if (parts.scheme == candidate.scheme == 'http' and parts.hostname == candidate.hostname
100
+ and parts.hostname in {'127.0.0.1', '::1', 'localhost'}
101
+ and parts.path == candidate.path and parts.query == candidate.query):
102
+ return True
103
+ return False
104
+
105
+
106
+ def _scope(value):
107
+ scopes = set((value or 'erp:read').split())
108
+ if not scopes or not scopes <= SCOPES.keys():
109
+ raise OAuthError('invalid_scope', 'Supported permissions: erp:read erp:write erp:admin.')
110
+ return ' '.join(sorted(scopes))
111
+
112
+
113
+ def _role(scope):
114
+ return max((SCOPES[s] for s in scope.split()), key=lambda r: ROLE_HIERARCHY[r])
115
+
116
+
117
+ @router.get('/.well-known/oauth-protected-resource')
118
+ @router.get('/.well-known/oauth-protected-resource/api/mcp')
119
+ def protected_resource(request: Request):
120
+ return JSONResponse({'resource': resource_url(request), 'resource_name': 'Lambda ERP',
121
+ 'authorization_servers': [issuer(request)], 'scopes_supported': list(SCOPES),
122
+ 'bearer_methods_supported': ['header']}, headers=NO_STORE)
123
+
124
+
125
+ @router.get('/.well-known/oauth-authorization-server')
126
+ def authorization_server(request: Request):
127
+ base = issuer(request) + '/api/mcp-oauth'
128
+ return JSONResponse({'issuer': issuer(request), 'authorization_endpoint': base + '/authorize',
129
+ 'token_endpoint': base + '/token', 'registration_endpoint': base + '/register',
130
+ 'revocation_endpoint': base + '/revoke', 'scopes_supported': list(SCOPES),
131
+ 'response_types_supported': ['code'],
132
+ 'grant_types_supported': ['authorization_code', 'refresh_token'],
133
+ 'code_challenge_methods_supported': ['S256'],
134
+ 'token_endpoint_auth_methods_supported': ['none', 'client_secret_post', 'client_secret_basic']},
135
+ headers=NO_STORE)
136
+
137
+
138
+ @router.get('/api/mcp-oauth/status')
139
+ def connection_status(request: Request, user: dict = Depends(require_interactive_user)):
140
+ db = get_db()
141
+ return {'mcp_url': resource_url(request), 'rest_enabled': _setting_enabled(db, 'rest_api_enabled'),
142
+ 'chat_enabled': _setting_enabled(db, 'chat_api_enabled')}
143
+
144
+
145
+ @router.post('/api/mcp-oauth/register')
146
+ async def register(request: Request):
147
+ _enabled()
148
+ raw = await request.body()
149
+ if len(raw) > 16384:
150
+ raise OAuthError('invalid_client_metadata', 'Registration is too large.')
151
+ try:
152
+ data = json.loads(raw)
153
+ except ValueError:
154
+ raise OAuthError('invalid_client_metadata', 'Expected JSON.')
155
+ if not isinstance(data, dict):
156
+ raise OAuthError('invalid_client_metadata', 'Expected an object.')
157
+ uris = data.get('redirect_uris')
158
+ if not isinstance(uris, list) or not 1 <= len(uris) <= 10:
159
+ raise OAuthError('invalid_redirect_uri', 'Supply 1–10 redirect URIs.')
160
+ for uri in uris:
161
+ _validate_redirect(uri)
162
+ method = data.get('token_endpoint_auth_method', 'client_secret_basic')
163
+ if not isinstance(method, str) or method not in {'none', 'client_secret_post', 'client_secret_basic'}:
164
+ raise OAuthError('invalid_client_metadata', 'Unsupported client authentication method.')
165
+ if (data.get('response_types', ['code']) != ['code']
166
+ or not isinstance(data.get('grant_types', []), list)
167
+ or any(not isinstance(g, str) for g in data.get('grant_types', []))
168
+ or not set(data.get('grant_types', ['authorization_code'])) <= {'authorization_code', 'refresh_token'}):
169
+ raise OAuthError('invalid_client_metadata', 'Only authorization code and refresh grants are supported.')
170
+ name = data.get('client_name') or 'MCP connector'
171
+ if not isinstance(name, str) or len(name) > 100:
172
+ raise OAuthError('invalid_client_metadata', 'Client name must be at most 100 characters.')
173
+ db = get_db()
174
+ timestamp = int(time.time())
175
+ # Bound unauthenticated registration growth without relying on process-local
176
+ # counters (the same service can run on several replicas).
177
+ if db.sql('SELECT COUNT(*) AS n FROM "MCP OAuth Client" WHERE created_at > ?', [timestamp - 3600])[0]['n'] >= 1000:
178
+ raise OAuthError('temporarily_unavailable', 'Registration limit reached. Try again later.', 429)
179
+ client_id, secret = secrets.token_urlsafe(24), secrets.token_urlsafe(32)
180
+ with db.atomic():
181
+ db.sql('INSERT INTO "MCP OAuth Client" (id,name,redirect_uris,auth_method,secret_hash,created_at) VALUES (?,?,?,?,?,?)',
182
+ [client_id, name, json.dumps(uris), method, digest(secret) if method != 'none' else None, timestamp])
183
+ result = {'client_id': client_id, 'client_id_issued_at': timestamp, 'client_name': name,
184
+ 'redirect_uris': uris, 'token_endpoint_auth_method': method,
185
+ 'grant_types': ['authorization_code', 'refresh_token'], 'response_types': ['code']}
186
+ if method != 'none':
187
+ result.update(client_secret=secret, client_secret_expires_at=0)
188
+ return JSONResponse(result, status_code=201, headers=NO_STORE)
189
+
190
+
191
+ @router.get('/api/mcp-oauth/authorize')
192
+ def authorize(request: Request):
193
+ _enabled()
194
+ p = dict(request.query_params)
195
+ if any(len(request.query_params.getlist(k)) != 1 for k in p) or len(str(request.url)) > 10000:
196
+ raise OAuthError('invalid_request', 'Invalid authorization parameters.')
197
+ client = _client(p.get('client_id'))
198
+ if not _redirect_matches(p.get('redirect_uri'), json.loads(client['redirect_uris'])):
199
+ raise OAuthError('invalid_request', 'Redirect URI does not match the registered client.')
200
+ if p.get('response_type') != 'code':
201
+ raise OAuthError('unsupported_response_type', 'Use response_type=code.')
202
+ if p.get('code_challenge_method') != 'S256' or not re.fullmatch(r'[A-Za-z0-9_-]{43}', p.get('code_challenge', '')):
203
+ raise OAuthError('invalid_request', 'S256 PKCE is required.')
204
+ if p.get('resource', resource_url(request)) != resource_url(request):
205
+ raise OAuthError('invalid_target', 'Unknown MCP resource.')
206
+ p['resource'] = resource_url(request)
207
+ p['scope'] = _scope(p.get('scope'))
208
+ request_id = secrets.token_hex(32)
209
+ db = get_db()
210
+ with db.atomic():
211
+ db.sql('DELETE FROM "MCP OAuth Request" WHERE expires_at < ?', [int(time.time())])
212
+ db.sql('INSERT INTO "MCP OAuth Request" (id,params,expires_at) VALUES (?,?,?)',
213
+ [request_id, json.dumps(p), int(time.time()) + 600])
214
+ response = RedirectResponse('/connect/authorize?request=' + request_id, status_code=303, headers=NO_STORE)
215
+ https = issuer(request).startswith('https://')
216
+ # Apple's social sign-in returns with a cross-site form POST.
217
+ response.set_cookie(RETURN_COOKIE, request_id, max_age=600, httponly=True,
218
+ secure=https, samesite='none' if https else 'lax', path='/api/auth')
219
+ return response
220
+
221
+
222
+ def _pending(request_id):
223
+ rows = get_db().sql('SELECT * FROM "MCP OAuth Request" WHERE id = ? AND consumed = 0 AND expires_at > ?',
224
+ [request_id, int(time.time())])
225
+ if not rows:
226
+ raise OAuthError('invalid_request', 'This connection request has expired. Start again in your app.')
227
+ return json.loads(rows[0]['params'])
228
+
229
+
230
+ @router.get('/api/mcp-oauth/consent')
231
+ def consent(request: Request, request_id: str, user: dict = Depends(require_interactive_user)):
232
+ _enabled()
233
+ p = _pending(request_id)
234
+ client = _client(p['client_id'])
235
+ # This signed CSRF token binds approval to the logged-in user and request.
236
+ csrf = jwt.encode({'sub': user['name'], 'rid': request_id, 'aud': 'mcp-consent',
237
+ 'exp': int(time.time()) + 600}, SECRET_KEY, algorithm=ALGORITHM)
238
+ maximum = min(ROLE_HIERARCHY[user['role']], ROLE_HIERARCHY[_role(p['scope'])])
239
+ return JSONResponse({'client_name': client['name'], 'redirect_uri': p['redirect_uri'],
240
+ 'roles': [r for r in ('viewer', 'manager', 'admin') if ROLE_HIERARCHY[r] <= maximum],
241
+ 'csrf_token': csrf}, headers=NO_STORE)
242
+
243
+
244
+ class ConsentDecision(BaseModel):
245
+ request_id: str
246
+ csrf_token: str
247
+ allow: bool
248
+ role: str = 'viewer'
249
+
250
+
251
+ @router.post('/api/mcp-oauth/consent')
252
+ def approve(data: ConsentDecision, request: Request, user: dict = Depends(require_interactive_user)):
253
+ _enabled()
254
+ origin = request.headers.get('origin')
255
+ if origin and origin != issuer(request):
256
+ raise OAuthError('access_denied', 'Invalid request origin.', 403)
257
+ try:
258
+ claims = jwt.decode(data.csrf_token, SECRET_KEY, algorithms=[ALGORITHM], audience='mcp-consent')
259
+ if claims['sub'] != user['name'] or claims['rid'] != data.request_id:
260
+ raise ValueError()
261
+ except (JWTError, KeyError, ValueError):
262
+ raise OAuthError('access_denied', 'Approval expired. Reload this page.', 403)
263
+ p = _pending(data.request_id)
264
+ client = _client(p['client_id'])
265
+ if data.allow and (data.role not in SCOPES.values() or ROLE_HIERARCHY[data.role] > min(ROLE_HIERARCHY[user['role']], ROLE_HIERARCHY[_role(p['scope'])])):
266
+ raise OAuthError('invalid_scope', 'The app cannot exceed your role or its requested access.')
267
+ query = {'state': p['state']} if 'state' in p else {}
268
+ db = get_db()
269
+ with db.atomic():
270
+ claimed = db.sql('UPDATE "MCP OAuth Request" SET consumed = 1 WHERE id = ? AND consumed = 0 AND expires_at > ? RETURNING id',
271
+ [data.request_id, int(time.time())])
272
+ if not claimed:
273
+ raise OAuthError('invalid_request', 'This approval has already been used or expired.')
274
+ if data.allow:
275
+ key_id, code = str(uuid.uuid4()), secrets.token_urlsafe(32)
276
+ scope = ' '.join(s for s in p['scope'].split() if ROLE_HIERARCHY[SCOPES[s]] <= ROLE_HIERARCHY[data.role]) or 'erp:read'
277
+ db.sql('INSERT INTO "Api Key" (id,name,owner,key_prefix,role,session_owner,created_at,app_type) VALUES (?,?,?,?,?,?,?,?)',
278
+ [key_id, client['name'], user['name'], 'OAuth', data.role, f"api:{user['name']}", now(), 'oauth'])
279
+ db.sql('INSERT INTO "MCP OAuth Code" (hash,client_id,key_id,redirect_uri,challenge,resource,scope,expires_at) VALUES (?,?,?,?,?,?,?,?)',
280
+ [digest(code), client['id'], key_id, p['redirect_uri'], p['code_challenge'], p['resource'], scope, int(time.time()) + 120])
281
+ query['code'] = code
282
+ else:
283
+ query['error'] = 'access_denied'
284
+ parts = urlsplit(p['redirect_uri'])
285
+ redirect = urlunsplit(parts._replace(query=urlencode(parse_qsl(parts.query) + list(query.items()))))
286
+ response = JSONResponse({'redirect_uri': redirect}, headers=NO_STORE)
287
+ response.delete_cookie(RETURN_COOKIE, path='/api/auth')
288
+ return response
289
+
290
+
291
+ def _authenticate_client(request, form):
292
+ client_id, secret = form.get('client_id'), form.get('client_secret', '')
293
+ header = request.headers.get('authorization', '')
294
+ basic = header.lower().startswith('basic ')
295
+ if basic:
296
+ try:
297
+ cid, secret = base64.b64decode(header[6:], validate=True).decode().split(':', 1)
298
+ cid, secret = unquote(cid), unquote(secret)
299
+ except (ValueError, UnicodeError):
300
+ raise OAuthError('invalid_client', 'Invalid client authentication.', 401)
301
+ if client_id and client_id != cid:
302
+ raise OAuthError('invalid_client', 'Conflicting client IDs.', 401)
303
+ client_id = cid
304
+ client = _client(client_id)
305
+ method = client['auth_method']
306
+ if (method == 'client_secret_basic' and not basic) or (method == 'client_secret_post' and basic):
307
+ raise OAuthError('invalid_client', 'Use the registered authentication method.', 401)
308
+ if method != 'none' and not hmac.compare_digest(digest(secret), client['secret_hash']):
309
+ raise OAuthError('invalid_client', 'Invalid client authentication.', 401)
310
+ return client
311
+
312
+
313
+ async def _token_form(request):
314
+ if len(await request.body()) > 16384:
315
+ raise OAuthError('invalid_request', 'Token request is too large.')
316
+ form = await request.form()
317
+ if any(len(form.getlist(k)) != 1 for k in form) or any(not isinstance(v, str) or len(v) > 4096 for v in form.values()):
318
+ raise OAuthError('invalid_request', 'Invalid token parameters.')
319
+ return form
320
+
321
+
322
+ def _grant_user(key_id):
323
+ principal = refresh_auth_principal({'credential_type': API_KEY_CREDENTIAL, 'api_key_id': key_id})
324
+ if not principal or principal['role'] == 'public_manager':
325
+ raise OAuthError('invalid_grant', 'Connection revoked or account no longer available.')
326
+ return principal
327
+
328
+
329
+ def _issue_tokens(db, grant):
330
+ access, refresh = 'erp_oauth_' + secrets.token_urlsafe(32), secrets.token_urlsafe(48)
331
+ for token, kind, ttl in ((access, 'access', ACCESS_TTL), (refresh, 'refresh', REFRESH_TTL)):
332
+ db.sql('INSERT INTO "MCP OAuth Token" (hash,kind,client_id,key_id,resource,scope,expires_at) VALUES (?,?,?,?,?,?,?)',
333
+ [digest(token), kind, grant['client_id'], grant['key_id'], grant['resource'], grant['scope'], int(time.time()) + ttl])
334
+ return {'access_token': access, 'token_type': 'Bearer', 'expires_in': ACCESS_TTL,
335
+ 'refresh_token': refresh, 'scope': grant['scope']}
336
+
337
+
338
+ @router.post('/api/mcp-oauth/token')
339
+ async def token_endpoint(request: Request):
340
+ _enabled()
341
+ form = await _token_form(request)
342
+ client = _authenticate_client(request, form)
343
+ kind = form.get('grant_type')
344
+ if kind not in {'authorization_code', 'refresh_token'}:
345
+ raise OAuthError('unsupported_grant_type', 'Use authorization_code or refresh_token.')
346
+ db = get_db()
347
+ table = 'MCP OAuth Code' if kind == 'authorization_code' else 'MCP OAuth Token'
348
+ token_hash = digest(form.get('code' if kind == 'authorization_code' else 'refresh_token', ''))
349
+ rows = db.sql(f'SELECT * FROM "{table}" WHERE hash = ? AND client_id = ?', [token_hash, client['id']])
350
+ if not rows or rows[0]['expires_at'] <= time.time():
351
+ raise OAuthError('invalid_grant', 'Invalid or expired credential.')
352
+ grant = rows[0]
353
+ if grant['resource'] != resource_url(request) or form.get('resource', grant['resource']) != grant['resource']:
354
+ raise OAuthError('invalid_target', 'Token is bound to a different MCP resource.')
355
+ if kind == 'authorization_code':
356
+ verifier = form.get('code_verifier', '')
357
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip('=')
358
+ if (not re.fullmatch(r'[A-Za-z0-9._~-]{43,128}', verifier)
359
+ or not hmac.compare_digest(challenge, grant['challenge'])
360
+ or form.get('redirect_uri') != grant['redirect_uri']):
361
+ raise OAuthError('invalid_grant', 'Invalid PKCE verifier or redirect URI.')
362
+ elif grant['kind'] != 'refresh':
363
+ raise OAuthError('invalid_grant', 'Expected a refresh token.')
364
+ if grant['consumed']:
365
+ # A replayed refresh token invalidates the entire grant/token family.
366
+ if kind == 'refresh_token':
367
+ with db.atomic():
368
+ db.sql('UPDATE "Api Key" SET revoked = 1 WHERE id = ?', [grant['key_id']])
369
+ raise OAuthError('invalid_grant', 'Credential already used. Connect again.')
370
+ _grant_user(grant['key_id'])
371
+ if form.get('scope'):
372
+ narrowed = _scope(form['scope'])
373
+ if not set(narrowed.split()) <= set(grant['scope'].split()):
374
+ raise OAuthError('invalid_scope', 'Refresh cannot expand permissions.')
375
+ grant['scope'] = narrowed
376
+ with db.atomic():
377
+ claimed = db.sql(f'UPDATE "{table}" SET consumed = 1 WHERE hash = ? AND consumed = 0 AND expires_at > ? RETURNING hash',
378
+ [token_hash, int(time.time())])
379
+ if claimed:
380
+ result = _issue_tokens(db, grant)
381
+ else:
382
+ # A second worker may have consumed the same refresh token after
383
+ # our initial read. Commit family revocation before returning error.
384
+ if kind == 'refresh_token':
385
+ db.sql('UPDATE "Api Key" SET revoked = 1 WHERE id = ?', [grant['key_id']])
386
+ result = None
387
+ if result is None:
388
+ raise OAuthError('invalid_grant', 'Credential already used or expired.')
389
+ return JSONResponse(result, headers=NO_STORE)
390
+
391
+
392
+ def authenticate_access_token(request, token):
393
+ _enabled()
394
+ rows = get_db().sql('SELECT * FROM "MCP OAuth Token" WHERE hash = ? AND kind = ? AND expires_at > ?',
395
+ [digest(token), 'access', int(time.time())])
396
+ if not rows or rows[0]['resource'] != resource_url(request):
397
+ raise HTTPException(401, 'Invalid or expired OAuth access token', headers=challenge_header(request))
398
+ grant = rows[0]
399
+ try:
400
+ user = _grant_user(grant['key_id'])
401
+ except OAuthError:
402
+ raise HTTPException(401, 'OAuth connection revoked', headers=challenge_header(request))
403
+ user['role'] = min((user['role'], _role(grant['scope'])), key=lambda r: ROLE_HIERARCHY[r])
404
+ user['oauth_scope_role'] = _role(grant['scope'])
405
+ # Return a user principal, not the isolated programmatic chat identity.
406
+ user['name'] = user['user_id']
407
+ db = get_db()
408
+ with db.atomic():
409
+ db.sql('UPDATE "Api Key" SET last_used_at = ? WHERE id = ?', [now(), grant['key_id']])
410
+ return user
411
+
412
+
413
+ @router.post('/api/mcp-oauth/revoke')
414
+ async def revoke_token(request: Request):
415
+ form = await _token_form(request)
416
+ client = _authenticate_client(request, form)
417
+ rows = get_db().sql('SELECT key_id FROM "MCP OAuth Token" WHERE hash = ? AND client_id = ?',
418
+ [digest(form.get('token', '')), client['id']])
419
+ if rows:
420
+ db = get_db()
421
+ with db.atomic():
422
+ db.sql('UPDATE "Api Key" SET revoked = 1 WHERE id = ?', [rows[0]['key_id']])
423
+ return JSONResponse({}, headers=NO_STORE)
@@ -29,6 +29,7 @@ import uuid
29
29
  import hmac
30
30
  import hashlib
31
31
  import secrets as _secrets
32
+ import re
32
33
 
33
34
  import httpx
34
35
  from jose import jwt, JWTError
@@ -474,7 +475,10 @@ async def _read_callback_params(request: Request) -> dict:
474
475
 
475
476
  def _login_redirect(request: Request, user_name: str, flow_id: str | None = None) -> RedirectResponse:
476
477
  token = create_access_token(user_name)
477
- resp = RedirectResponse(url="/", status_code=303)
478
+ mcp_request = request.cookies.get('lambda_erp_mcp_return', '')
479
+ target = '/connect/authorize?request=' + mcp_request if re.fullmatch(r'[a-f0-9]{64}', mcp_request) else '/'
480
+ resp = RedirectResponse(url=target, status_code=303)
481
+ resp.delete_cookie('lambda_erp_mcp_return', path='/api/auth')
478
482
  set_auth_cookie(request, resp, token)
479
483
  if flow_id:
480
484
  _clear_flow_cookie(resp, flow_id)
@@ -4,9 +4,9 @@ LLM agents (Claude, Codex, …) over MCP's Streamable HTTP transport.
4
4
  Reuses everything the chat already has, so there's almost no new logic:
5
5
  * schemas — build_tools() (the live, plugin-widened tool list).
6
6
  * execution — the same TOOL_HANDLERS (which run validate()).
7
- * auth — get_current_user: a Bearer API key acts AS its user at the key's
8
- role, exactly like the REST API, gated by the same
9
- `rest_api_enabled` Settings flag. No separate MCP credential.
7
+ * auth — Bearer API keys or resource-bound OAuth tokens, acting as their
8
+ owner with a live role cap. Both use `rest_api_enabled`.
9
+ Ambient session cookies are never MCP credentials.
10
10
 
11
11
  Because the tool schemas come from the live registries, a plugin that registers
12
12
  doctypes/masters (e.g. the internal CRM's lead/contact/activity) is exposed here
@@ -16,12 +16,15 @@ Transport: a single POST /api/mcp speaking JSON-RPC 2.0 (request → JSON result
16
16
  notifications → 202, no body). That's the minimum a tool-only server needs.
17
17
  """
18
18
  import json
19
+ import os
19
20
 
20
21
  from fastapi import APIRouter, HTTPException, Request
21
22
  from fastapi.responses import JSONResponse, Response
22
23
 
23
24
  from lambda_erp import get_app_version
24
- from api.auth import get_current_user
25
+ from api.auth import _lookup_api_key, _setting_enabled, make_auth_principal, API_KEY_CREDENTIAL
26
+ from api.mcp_oauth import authenticate_access_token, challenge_header, issuer
27
+ from lambda_erp.database import get_db
25
28
  from api import services
26
29
  from api import chat as chat_mod
27
30
  from api.chat import TOOL_HANDLERS, build_tools
@@ -30,6 +33,7 @@ from api.tool_permissions import tool_allowed
30
33
  router = APIRouter(tags=["mcp"])
31
34
 
32
35
  PROTOCOL_VERSION = "2025-06-18"
36
+ SUPPORTED_PROTOCOL_VERSIONS = frozenset({'2025-03-26', PROTOCOL_VERSION})
33
37
 
34
38
  # Chat-session-only tools have no meaning without a chat session — keep them out
35
39
  # of the MCP surface (an MCP client owns its own context).
@@ -44,18 +48,35 @@ _EXCLUDE = {
44
48
  "apply_company_setup",
45
49
  }
46
50
 
51
+ _READ_ONLY = {
52
+ 'list_documents', 'get_document_fields', 'get_document', 'get_master_fields',
53
+ 'search_masters', 'get_report', 'get_current_time', 'query_dataset',
54
+ 'preview_bank_statement_attachments', 'list_bank_reconciliation_queue',
55
+ 'suggest_bank_reconciliation',
56
+ }
57
+
47
58
 
48
59
  def _allowed(name: str, role) -> bool:
49
60
  return name not in _EXCLUDE and tool_allowed(name, role)
50
61
 
51
62
 
52
63
  def _require_caller(request: Request) -> dict:
53
- """A valid Bearer API key is mandatory for MCP — no cookie/public fallback.
54
- get_current_user then validates the key and applies `rest_api_enabled`."""
64
+ """MCP accepts API keys and OAuth tokens, never ambient browser cookies."""
55
65
  auth = request.headers.get("authorization", "")
56
66
  if not auth.lower().startswith("bearer "):
57
- raise HTTPException(status_code=401, detail="MCP requires a Bearer API key")
58
- return get_current_user(request)
67
+ raise HTTPException(status_code=401, detail="Connect your account or supply a Bearer API key", headers=challenge_header(request))
68
+ token = auth[7:].strip()
69
+ if token.startswith('erp_oauth_'):
70
+ return authenticate_access_token(request, token)
71
+ db = get_db()
72
+ if not _setting_enabled(db, 'rest_api_enabled'):
73
+ raise HTTPException(403, 'REST API / MCP access is disabled')
74
+ try:
75
+ key, owner, role = _lookup_api_key(db, token)
76
+ except HTTPException as exc:
77
+ raise HTTPException(exc.status_code, exc.detail, headers=challenge_header(request))
78
+ owner = dict(owner, role=role)
79
+ return make_auth_principal(owner, API_KEY_CREDENTIAL, api_key_id=key['id'])
59
80
 
60
81
 
61
82
  def _tools(role) -> list:
@@ -68,6 +89,16 @@ def _tools(role) -> list:
68
89
  "name": fn["name"],
69
90
  "description": fn.get("description", ""),
70
91
  "inputSchema": fn.get("parameters") or {"type": "object", "properties": {}},
92
+ "annotations": {
93
+ "readOnlyHint": fn['name'] in _READ_ONLY,
94
+ # Unknown extension actions are conservatively treated as writes.
95
+ "destructiveHint": fn['name'] not in _READ_ONLY | {'generate_document_pdf'},
96
+ "openWorldHint": fn['name'] in services.REGISTERED_ACTIONS,
97
+ },
98
+ "securitySchemes": [{"type": "oauth2", "scopes": [
99
+ 'erp:read' if tool_allowed(fn['name'], 'viewer') else
100
+ 'erp:write' if tool_allowed(fn['name'], 'manager') else 'erp:admin'
101
+ ]}],
71
102
  })
72
103
  return out
73
104
 
@@ -116,6 +147,10 @@ def _rpc_error(mid, code: int, message: str) -> dict:
116
147
  def _handle(msg: dict, user: dict):
117
148
  """Handle one JSON-RPC message. Returns a response dict, or None for a
118
149
  notification (no `id`)."""
150
+ if msg.get('jsonrpc') != '2.0' or not isinstance(msg.get('method'), str):
151
+ return _rpc_error(msg.get('id'), -32600, 'Invalid Request')
152
+ if 'params' in msg and not isinstance(msg['params'], dict):
153
+ return _rpc_error(msg.get('id'), -32602, 'Expected object parameters')
119
154
  method = msg.get("method")
120
155
  mid = msg.get("id")
121
156
  is_notification = "id" not in msg
@@ -124,8 +159,12 @@ def _handle(msg: dict, user: dict):
124
159
  return None if is_notification else {"jsonrpc": "2.0", "id": mid, "result": payload}
125
160
 
126
161
  if method == "initialize":
162
+ requested_version = (msg.get('params') or {}).get('protocolVersion', PROTOCOL_VERSION)
163
+ if not isinstance(requested_version, str):
164
+ return _rpc_error(mid, -32602, 'Expected a string protocolVersion')
127
165
  return result({
128
- "protocolVersion": PROTOCOL_VERSION,
166
+ "protocolVersion": (requested_version if requested_version in SUPPORTED_PROTOCOL_VERSIONS
167
+ else PROTOCOL_VERSION),
129
168
  "capabilities": {"tools": {"listChanged": False}},
130
169
  "serverInfo": {"name": "lambda-erp", "version": get_app_version()},
131
170
  })
@@ -156,13 +195,28 @@ def _handle(msg: dict, user: dict):
156
195
 
157
196
  @router.post("/mcp")
158
197
  async def mcp_endpoint(request: Request):
198
+ origin = request.headers.get('origin')
199
+ allowed_origins = {issuer(request), *os.environ.get('MCP_ALLOWED_ORIGINS', '').split(',')}
200
+ if origin and origin not in allowed_origins:
201
+ raise HTTPException(403, 'Invalid Origin')
202
+ # Authentication discovery must work before the client has negotiated an
203
+ # MCP version. Otherwise a newer connector gets 400 instead of the 401
204
+ # challenge that tells it how to sign in.
159
205
  user = _require_caller(request)
160
206
  try:
161
207
  body = await request.json()
162
208
  except Exception:
163
209
  return JSONResponse(_rpc_error(None, -32700, "Parse error"), status_code=400)
164
210
 
165
- # JSON-RPC batch (a list) or a single message.
211
+ # Keep legacy batch support, but reject malformed messages rather than 500.
212
+ if not isinstance(body, (dict, list)) or (isinstance(body, list) and (not body or any(not isinstance(m, dict) for m in body))):
213
+ return JSONResponse(_rpc_error(None, -32600, "Invalid Request"), status_code=400)
214
+ # initialize negotiates the revision in its JSON-RPC payload. The version
215
+ # header is enforced on subsequent calls, after the client has our answer.
216
+ initializing = isinstance(body, dict) and body.get('method') == 'initialize'
217
+ version = request.headers.get('mcp-protocol-version')
218
+ if not initializing and version and version not in SUPPORTED_PROTOCOL_VERSIONS:
219
+ raise HTTPException(400, 'Unsupported MCP protocol version')
166
220
  if isinstance(body, list):
167
221
  responses = [r for r in (_handle(m, user) for m in body) if r is not None]
168
222
  return JSONResponse(responses) if responses else Response(status_code=202)
@@ -170,3 +224,9 @@ async def mcp_endpoint(request: Request):
170
224
  if resp is None:
171
225
  return Response(status_code=202)
172
226
  return JSONResponse(resp)
227
+
228
+
229
+ @router.get('/mcp')
230
+ def mcp_no_stream():
231
+ # A stateless JSON transport does not offer a server-initiated SSE stream.
232
+ return Response(status_code=405, headers={'Allow': 'POST'})
@@ -57,7 +57,7 @@ export async function request<T>(path: string, options?: RequestInit): Promise<T
57
57
  throw new ApiError(401, "Session expired");
58
58
  }
59
59
  const body = await res.json().catch(() => ({ detail: res.statusText }));
60
- throw new ApiError(res.status, formatErrorDetail(body.detail));
60
+ throw new ApiError(res.status, formatErrorDetail(body.detail || body.error_description));
61
61
  }
62
62
  return res.json();
63
63
  }
@@ -74,7 +74,7 @@ async function multipartRequest<T>(path: string, form: FormData): Promise<T> {
74
74
  throw new ApiError(401, "Session expired");
75
75
  }
76
76
  const body = await res.json().catch(() => ({ detail: res.statusText }));
77
- throw new ApiError(res.status, formatErrorDetail(body.detail));
77
+ throw new ApiError(res.status, formatErrorDetail(body.detail || body.error_description));
78
78
  }
79
79
  return res.json() as Promise<T>;
80
80
  }
@@ -746,17 +746,21 @@ export const api = {
746
746
  getMySettings: () => request<Record<string, string>>("/auth/my-settings"),
747
747
  updateMySettings: (data: Record<string, string>) =>
748
748
  request<Record<string, string>>("/auth/my-settings", { method: "PUT", body: JSON.stringify(data) }),
749
+ getConnectionStatus: () => request<{ mcp_url: string; rest_enabled: boolean; chat_enabled: boolean }>("/mcp-oauth/status"),
750
+ getMcpConsent: (requestId: string) => request<{ client_name: string; redirect_uri: string; roles: string[]; csrf_token: string }>(`/mcp-oauth/consent?request_id=${encodeURIComponent(requestId)}`),
751
+ approveMcp: (request_id: string, csrf_token: string, allow: boolean, role: string) =>
752
+ request<{ redirect_uri: string }>("/mcp-oauth/consent", { method: "POST", body: JSON.stringify({ request_id, csrf_token, allow, role }) }),
749
753
  getApiKeys: () =>
750
754
  request<Array<{
751
755
  id: string; name: string; user?: string; role: string; key_prefix: string;
752
- created_at?: string; last_used_at?: string | null; revoked: boolean;
756
+ created_at?: string; last_used_at?: string | null; revoked: boolean; app_type?: string;
753
757
  owner_full_name?: string | null; owner_email?: string | null; is_mine?: boolean;
754
758
  }>>("/auth/api-keys"),
755
- createApiKey: (name: string, role: string) =>
759
+ createApiKey: (name: string, role: string, app_type: "claude_code" | "codex" | "other" = "other") =>
756
760
  request<{
757
761
  id: string; name: string; user?: string; role: string; key_prefix: string; token: string;
758
762
  owner_full_name?: string | null; owner_email?: string | null; is_mine?: boolean;
759
- }>("/auth/api-keys", { method: "POST", body: JSON.stringify({ name, role }) }),
763
+ }>("/auth/api-keys", { method: "POST", body: JSON.stringify({ name, role, app_type }) }),
760
764
  revokeApiKey: (id: string) =>
761
765
  request<{ id: string; revoked: boolean }>(
762
766
  `/auth/api-keys/${encodeURIComponent(id)}/revoke`, { method: "POST" }),
@@ -1695,6 +1695,25 @@ class Database:
1695
1695
  revoked INTEGER DEFAULT 0
1696
1696
  )""",
1697
1697
 
1698
+ """CREATE TABLE IF NOT EXISTS "MCP OAuth Client" (
1699
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, redirect_uris TEXT NOT NULL,
1700
+ auth_method TEXT NOT NULL, secret_hash TEXT, created_at INTEGER NOT NULL
1701
+ )""",
1702
+ """CREATE TABLE IF NOT EXISTS "MCP OAuth Request" (
1703
+ id TEXT PRIMARY KEY, params TEXT NOT NULL, expires_at INTEGER NOT NULL,
1704
+ consumed INTEGER DEFAULT 0
1705
+ )""",
1706
+ """CREATE TABLE IF NOT EXISTS "MCP OAuth Code" (
1707
+ hash TEXT PRIMARY KEY, client_id TEXT NOT NULL, key_id TEXT NOT NULL,
1708
+ redirect_uri TEXT NOT NULL, challenge TEXT NOT NULL, resource TEXT NOT NULL,
1709
+ scope TEXT NOT NULL, expires_at INTEGER NOT NULL, consumed INTEGER DEFAULT 0
1710
+ )""",
1711
+ """CREATE TABLE IF NOT EXISTS "MCP OAuth Token" (
1712
+ hash TEXT PRIMARY KEY, kind TEXT NOT NULL, client_id TEXT NOT NULL,
1713
+ key_id TEXT NOT NULL, resource TEXT NOT NULL, scope TEXT NOT NULL,
1714
+ expires_at INTEGER NOT NULL, consumed INTEGER DEFAULT 0
1715
+ )""",
1716
+
1698
1717
  # Exchange rates for multi-currency. A lookup carries forward the
1699
1718
  # most recent rate on/before a transaction's date; the rate is then
1700
1719
  # snapshotted onto the document, so editing this table never changes
@@ -2855,6 +2874,10 @@ def _m038_customer_website(db: "Database") -> None:
2855
2874
  db.ensure_column("Customer", "website", "TEXT")
2856
2875
 
2857
2876
 
2877
+ def _m039_connection_app_type(db: "Database") -> None:
2878
+ db.ensure_column("Api Key", "app_type", "TEXT DEFAULT 'other'")
2879
+
2880
+
2858
2881
  Database.MIGRATIONS = [
2859
2882
  (1, "chat_message_session_id", _m001_chat_message_session_id),
2860
2883
  (2, "chat_session_user_id", _m002_chat_session_user_id),
@@ -2894,6 +2917,7 @@ Database.MIGRATIONS = [
2894
2917
  (36, "external_identity_integrity", _m036_external_identity_integrity),
2895
2918
  (37, "pricing_record_lifecycle", _m037_pricing_record_lifecycle),
2896
2919
  (38, "customer_website", _m038_customer_website),
2920
+ (39, "connection_app_type", _m039_connection_app_type),
2897
2921
  ]
2898
2922
 
2899
2923
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "1.1.2"
3
+ version = "1.1.4"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes