ledger-mcp 0.1.0__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.
- ledger_mcp-0.1.0/.gitignore +8 -0
- ledger_mcp-0.1.0/LICENSE +21 -0
- ledger_mcp-0.1.0/PKG-INFO +121 -0
- ledger_mcp-0.1.0/README.md +93 -0
- ledger_mcp-0.1.0/claude-code-config.json +11 -0
- ledger_mcp-0.1.0/ledger_mcp/__init__.py +5 -0
- ledger_mcp-0.1.0/ledger_mcp/__main__.py +3 -0
- ledger_mcp-0.1.0/ledger_mcp/server.py +371 -0
- ledger_mcp-0.1.0/pyproject.toml +36 -0
- ledger_mcp-0.1.0/server.json +30 -0
ledger_mcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Novadyne (an Infai company)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ledger-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Double-entry accounting MCP server for AI agents — create accounts, post balanced journal entries, pull trial-balance / general-ledger reports. Paid per call via x402 micropayments (USDC on Base).
|
|
5
|
+
Project-URL: Homepage, https://ledger.novadyne.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/novadyne-hq/ledger-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/novadyne-hq/ledger-mcp/issues
|
|
8
|
+
Author-email: Novadyne <support@novadyne.ai>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: accounting,agent-payments,base,bookkeeping,claude-code,double-entry,finance,ledger,mcp,mcp-server,micropayments,usdc,x402
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: eth-account>=0.11
|
|
26
|
+
Requires-Dist: mcp>=1.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# Ledger MCP
|
|
30
|
+
|
|
31
|
+
**Double-entry accounting for AI agents.** An MCP server over the
|
|
32
|
+
[Ledger API](https://ledger.novadyne.ai) — create a chart of accounts, post
|
|
33
|
+
balanced journal entries, and pull trial-balance / general-ledger reports,
|
|
34
|
+
all from inside your agent.
|
|
35
|
+
|
|
36
|
+
Ledger is **agent-native**: it's paid **per call via [x402](https://x402.org)
|
|
37
|
+
micropayments** (USDC on the Base network), so an agent can use real
|
|
38
|
+
double-entry bookkeeping without an account, API key, or subscription — it just
|
|
39
|
+
pays a fraction of a cent per call from its own wallet.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uvx ledger-mcp
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or add it to your MCP client (`claude-code-config.json` / Claude Desktop):
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"mcpServers": {
|
|
52
|
+
"ledger": {
|
|
53
|
+
"command": "uvx",
|
|
54
|
+
"args": ["ledger-mcp"],
|
|
55
|
+
"env": {
|
|
56
|
+
"LEDGER_X402_PRIVATE_KEY": "0xYOUR_FUNDED_BASE_WALLET_KEY"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Payment
|
|
64
|
+
|
|
65
|
+
Most tools are **paid per call** (reads ~$0.002, writes ~$0.01, in USDC on Base):
|
|
66
|
+
|
|
67
|
+
- Set `LEDGER_X402_PRIVATE_KEY` to the private key of a **funded Base wallet**
|
|
68
|
+
(holding USDC). The server signs the x402 (EIP-3009) payment automatically on
|
|
69
|
+
each call — gasless, you only spend the USDC the call costs.
|
|
70
|
+
- Without a key, `health` and `discover` still work, and paid tools return the
|
|
71
|
+
exact price plus how to enable payment (nothing is spent).
|
|
72
|
+
- Use a **dedicated low-balance wallet** for your agent — never your main key.
|
|
73
|
+
The balance is the blast radius.
|
|
74
|
+
|
|
75
|
+
Run `discover` to see every endpoint and its current price before spending.
|
|
76
|
+
|
|
77
|
+
## Tools
|
|
78
|
+
|
|
79
|
+
| Tool | Cost | What it does |
|
|
80
|
+
|------|------|--------------|
|
|
81
|
+
| `health` | free | API status + version |
|
|
82
|
+
| `discover` | free | List paid endpoints + x402 prices |
|
|
83
|
+
| `create_account` | write | Add an account (asset/liability/equity/revenue/expense) |
|
|
84
|
+
| `list_accounts` | read | Chart of accounts + balances |
|
|
85
|
+
| `get_account` | read | One account's balance (optionally as-of a date) |
|
|
86
|
+
| `post_transaction` | write | Record a balanced journal entry |
|
|
87
|
+
| `list_transactions` | read | Journal, newest first |
|
|
88
|
+
| `get_transaction` | read | One transaction with all entries |
|
|
89
|
+
| `reverse_transaction` | write | Post a reversing entry |
|
|
90
|
+
| `trial_balance` | read | All balances + debit/credit totals |
|
|
91
|
+
| `general_ledger` | read | Per-account detail over a date range |
|
|
92
|
+
|
|
93
|
+
## The one rule: entries sum to zero
|
|
94
|
+
|
|
95
|
+
Every transaction is two or more entries whose **signed amounts sum to exactly
|
|
96
|
+
0**. Amounts are integers in **minor units (cents)**: a **debit is positive**, a
|
|
97
|
+
**credit is negative**.
|
|
98
|
+
|
|
99
|
+
Record a $500 cash sale (debit Cash, credit Sales Revenue):
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"date": "2026-06-19",
|
|
104
|
+
"description": "Cash sale",
|
|
105
|
+
"entries": [
|
|
106
|
+
{"account_id": 1, "amount": 50000, "memo": "cash in"},
|
|
107
|
+
{"account_id": 2, "amount": -50000, "memo": "sales revenue"}
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The server rejects an unbalanced transaction before spending.
|
|
113
|
+
|
|
114
|
+
## Links
|
|
115
|
+
|
|
116
|
+
- Docs & API: <https://ledger.novadyne.ai>
|
|
117
|
+
- API base: `https://ledger-api.novadyne.ai`
|
|
118
|
+
- x402 discovery: `https://ledger-api.novadyne.ai/.well-known/x402`
|
|
119
|
+
|
|
120
|
+
MIT licensed. By [Novadyne](https://novadyne.ai). The API backend is operated by
|
|
121
|
+
Novadyne; this package is the open client that talks to it.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Ledger MCP
|
|
2
|
+
|
|
3
|
+
**Double-entry accounting for AI agents.** An MCP server over the
|
|
4
|
+
[Ledger API](https://ledger.novadyne.ai) — create a chart of accounts, post
|
|
5
|
+
balanced journal entries, and pull trial-balance / general-ledger reports,
|
|
6
|
+
all from inside your agent.
|
|
7
|
+
|
|
8
|
+
Ledger is **agent-native**: it's paid **per call via [x402](https://x402.org)
|
|
9
|
+
micropayments** (USDC on the Base network), so an agent can use real
|
|
10
|
+
double-entry bookkeeping without an account, API key, or subscription — it just
|
|
11
|
+
pays a fraction of a cent per call from its own wallet.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uvx ledger-mcp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or add it to your MCP client (`claude-code-config.json` / Claude Desktop):
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"mcpServers": {
|
|
24
|
+
"ledger": {
|
|
25
|
+
"command": "uvx",
|
|
26
|
+
"args": ["ledger-mcp"],
|
|
27
|
+
"env": {
|
|
28
|
+
"LEDGER_X402_PRIVATE_KEY": "0xYOUR_FUNDED_BASE_WALLET_KEY"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Payment
|
|
36
|
+
|
|
37
|
+
Most tools are **paid per call** (reads ~$0.002, writes ~$0.01, in USDC on Base):
|
|
38
|
+
|
|
39
|
+
- Set `LEDGER_X402_PRIVATE_KEY` to the private key of a **funded Base wallet**
|
|
40
|
+
(holding USDC). The server signs the x402 (EIP-3009) payment automatically on
|
|
41
|
+
each call — gasless, you only spend the USDC the call costs.
|
|
42
|
+
- Without a key, `health` and `discover` still work, and paid tools return the
|
|
43
|
+
exact price plus how to enable payment (nothing is spent).
|
|
44
|
+
- Use a **dedicated low-balance wallet** for your agent — never your main key.
|
|
45
|
+
The balance is the blast radius.
|
|
46
|
+
|
|
47
|
+
Run `discover` to see every endpoint and its current price before spending.
|
|
48
|
+
|
|
49
|
+
## Tools
|
|
50
|
+
|
|
51
|
+
| Tool | Cost | What it does |
|
|
52
|
+
|------|------|--------------|
|
|
53
|
+
| `health` | free | API status + version |
|
|
54
|
+
| `discover` | free | List paid endpoints + x402 prices |
|
|
55
|
+
| `create_account` | write | Add an account (asset/liability/equity/revenue/expense) |
|
|
56
|
+
| `list_accounts` | read | Chart of accounts + balances |
|
|
57
|
+
| `get_account` | read | One account's balance (optionally as-of a date) |
|
|
58
|
+
| `post_transaction` | write | Record a balanced journal entry |
|
|
59
|
+
| `list_transactions` | read | Journal, newest first |
|
|
60
|
+
| `get_transaction` | read | One transaction with all entries |
|
|
61
|
+
| `reverse_transaction` | write | Post a reversing entry |
|
|
62
|
+
| `trial_balance` | read | All balances + debit/credit totals |
|
|
63
|
+
| `general_ledger` | read | Per-account detail over a date range |
|
|
64
|
+
|
|
65
|
+
## The one rule: entries sum to zero
|
|
66
|
+
|
|
67
|
+
Every transaction is two or more entries whose **signed amounts sum to exactly
|
|
68
|
+
0**. Amounts are integers in **minor units (cents)**: a **debit is positive**, a
|
|
69
|
+
**credit is negative**.
|
|
70
|
+
|
|
71
|
+
Record a $500 cash sale (debit Cash, credit Sales Revenue):
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"date": "2026-06-19",
|
|
76
|
+
"description": "Cash sale",
|
|
77
|
+
"entries": [
|
|
78
|
+
{"account_id": 1, "amount": 50000, "memo": "cash in"},
|
|
79
|
+
{"account_id": 2, "amount": -50000, "memo": "sales revenue"}
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The server rejects an unbalanced transaction before spending.
|
|
85
|
+
|
|
86
|
+
## Links
|
|
87
|
+
|
|
88
|
+
- Docs & API: <https://ledger.novadyne.ai>
|
|
89
|
+
- API base: `https://ledger-api.novadyne.ai`
|
|
90
|
+
- x402 discovery: `https://ledger-api.novadyne.ai/.well-known/x402`
|
|
91
|
+
|
|
92
|
+
MIT licensed. By [Novadyne](https://novadyne.ai). The API backend is operated by
|
|
93
|
+
Novadyne; this package is the open client that talks to it.
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Ledger MCP Server — double-entry accounting for AI agents.
|
|
2
|
+
|
|
3
|
+
Exposes the Ledger API (https://ledger-api.novadyne.ai) as MCP tools:
|
|
4
|
+
- create_account: add an account to the chart of accounts
|
|
5
|
+
- list_accounts / get_account: read the chart of accounts + balances
|
|
6
|
+
- post_transaction: record a balanced journal entry (entries sum to zero)
|
|
7
|
+
- list_transactions / get_transaction: read the journal
|
|
8
|
+
- reverse_transaction: post a reversing entry
|
|
9
|
+
- trial_balance: every account balance + debit/credit totals as of a date
|
|
10
|
+
- general_ledger: per-account transaction history over a date range
|
|
11
|
+
|
|
12
|
+
The Ledger API is **paid per call via x402 micropayments** (USDC on Base).
|
|
13
|
+
This server pays automatically when LEDGER_X402_PRIVATE_KEY is set to a
|
|
14
|
+
funded Base wallet; without it, the read/write tools return the price and
|
|
15
|
+
how to enable payment (health, schema discovery, and pricing work keyless).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import secrets
|
|
21
|
+
import time
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
import urllib.error
|
|
25
|
+
|
|
26
|
+
from mcp.server import FastMCP
|
|
27
|
+
|
|
28
|
+
DEFAULT_API_URL = "https://ledger-api.novadyne.ai"
|
|
29
|
+
API_URL = os.environ.get("LEDGER_API_URL", DEFAULT_API_URL).rstrip("/")
|
|
30
|
+
|
|
31
|
+
# A funded Base wallet private key (hex, with or without 0x). When set, the
|
|
32
|
+
# server signs x402 EIP-3009 payments automatically so paid tools just work.
|
|
33
|
+
PRIVATE_KEY = (
|
|
34
|
+
os.environ.get("LEDGER_X402_PRIVATE_KEY")
|
|
35
|
+
or os.environ.get("X402_PRIVATE_KEY")
|
|
36
|
+
or ""
|
|
37
|
+
).strip()
|
|
38
|
+
|
|
39
|
+
# Native USDC on Base — used only as a sanity check against the 402 challenge.
|
|
40
|
+
USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
41
|
+
|
|
42
|
+
mcp = FastMCP(
|
|
43
|
+
"Ledger",
|
|
44
|
+
instructions=(
|
|
45
|
+
"Ledger is a double-entry accounting API for AI agents. "
|
|
46
|
+
"Create accounts, post balanced journal entries (every transaction's "
|
|
47
|
+
"entries must sum to zero), and pull trial-balance / general-ledger "
|
|
48
|
+
"reports. Amounts are signed integers in minor units (cents): a debit "
|
|
49
|
+
"is positive, a credit is negative, and the entries of one transaction "
|
|
50
|
+
"must sum to exactly 0. The API is paid per call via x402 micropayments "
|
|
51
|
+
"(USDC on Base) — set LEDGER_X402_PRIVATE_KEY to a funded Base wallet to "
|
|
52
|
+
"pay automatically. Start with trial_balance or list_accounts to see the "
|
|
53
|
+
"current books."
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- x402 payment ---
|
|
59
|
+
|
|
60
|
+
class PaymentError(Exception):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _eip3009_header(accept: dict) -> str:
|
|
65
|
+
"""Build a base64 X-PAYMENT header (x402 v2) by signing an EIP-3009
|
|
66
|
+
TransferWithAuthorization for the amount the 402 challenge asks for."""
|
|
67
|
+
try:
|
|
68
|
+
from eth_account import Account
|
|
69
|
+
except ImportError:
|
|
70
|
+
raise PaymentError(
|
|
71
|
+
"eth-account is required to pay. Reinstall with `uvx ledger-mcp` "
|
|
72
|
+
"(it is a declared dependency) or `pip install eth-account`."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if not PRIVATE_KEY:
|
|
76
|
+
raise PaymentError("no wallet configured")
|
|
77
|
+
|
|
78
|
+
asset = accept.get("asset", "")
|
|
79
|
+
if asset and asset.lower() != USDC_BASE.lower():
|
|
80
|
+
raise PaymentError(f"unexpected asset {asset}; expected USDC on Base")
|
|
81
|
+
|
|
82
|
+
acct = Account.from_key(PRIVATE_KEY)
|
|
83
|
+
value = str(accept.get("maxAmountRequired") or accept.get("amount") or "0")
|
|
84
|
+
pay_to = accept["payTo"]
|
|
85
|
+
timeout = int(accept.get("maxTimeoutSeconds", 60))
|
|
86
|
+
now = int(time.time())
|
|
87
|
+
authorization = {
|
|
88
|
+
"from": acct.address,
|
|
89
|
+
"to": pay_to,
|
|
90
|
+
"value": value,
|
|
91
|
+
"validAfter": "0",
|
|
92
|
+
"validBefore": str(now + max(timeout, 60)),
|
|
93
|
+
"nonce": "0x" + secrets.token_hex(32),
|
|
94
|
+
}
|
|
95
|
+
extra = accept.get("extra") or {}
|
|
96
|
+
domain = {
|
|
97
|
+
"name": extra.get("name", "USD Coin"),
|
|
98
|
+
"version": extra.get("version", "2"),
|
|
99
|
+
"chainId": 8453,
|
|
100
|
+
"verifyingContract": asset or USDC_BASE,
|
|
101
|
+
}
|
|
102
|
+
types = {
|
|
103
|
+
"TransferWithAuthorization": [
|
|
104
|
+
{"name": "from", "type": "address"},
|
|
105
|
+
{"name": "to", "type": "address"},
|
|
106
|
+
{"name": "value", "type": "uint256"},
|
|
107
|
+
{"name": "validAfter", "type": "uint256"},
|
|
108
|
+
{"name": "validBefore", "type": "uint256"},
|
|
109
|
+
{"name": "nonce", "type": "bytes32"},
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
message = {
|
|
113
|
+
"from": acct.address,
|
|
114
|
+
"to": pay_to,
|
|
115
|
+
"value": int(value),
|
|
116
|
+
"validAfter": 0,
|
|
117
|
+
"validBefore": int(authorization["validBefore"]),
|
|
118
|
+
"nonce": bytes.fromhex(authorization["nonce"][2:]),
|
|
119
|
+
}
|
|
120
|
+
signed = Account.sign_typed_data(PRIVATE_KEY, domain, types, message)
|
|
121
|
+
sig = signed.signature.hex()
|
|
122
|
+
if not sig.startswith("0x"):
|
|
123
|
+
sig = "0x" + sig
|
|
124
|
+
payload = {
|
|
125
|
+
"x402Version": 2,
|
|
126
|
+
"scheme": accept.get("scheme", "exact"),
|
|
127
|
+
"network": accept.get("network", "eip155:8453"),
|
|
128
|
+
"payload": {
|
|
129
|
+
"signature": sig,
|
|
130
|
+
"authorization": authorization,
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
import base64
|
|
134
|
+
return base64.b64encode(json.dumps(payload).encode()).decode()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _request(method: str, path: str, query: dict | None = None, body: dict | None = None) -> dict:
|
|
138
|
+
"""Call the Ledger API, paying the x402 challenge automatically if a wallet
|
|
139
|
+
is configured. Returns {"ok": True, "data": ...} or {"ok": False, "error": ...}."""
|
|
140
|
+
url = f"{API_URL}{path}"
|
|
141
|
+
if query:
|
|
142
|
+
clean = {k: v for k, v in query.items() if v is not None}
|
|
143
|
+
if clean:
|
|
144
|
+
url += "?" + urllib.parse.urlencode(clean)
|
|
145
|
+
|
|
146
|
+
def _do(payment_header: str | None):
|
|
147
|
+
headers = {"User-Agent": "ledger-mcp/0.1", "Accept": "application/json"}
|
|
148
|
+
data = None
|
|
149
|
+
if body is not None:
|
|
150
|
+
headers["Content-Type"] = "application/json"
|
|
151
|
+
data = json.dumps(body).encode()
|
|
152
|
+
if payment_header:
|
|
153
|
+
headers["X-PAYMENT"] = payment_header
|
|
154
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
155
|
+
return urllib.request.urlopen(req, timeout=90)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
with _do(None) as resp:
|
|
159
|
+
return {"ok": True, "data": json.loads(resp.read() or "null")}
|
|
160
|
+
except urllib.error.HTTPError as e:
|
|
161
|
+
if e.code != 402:
|
|
162
|
+
detail = e.read().decode() if e.fp else ""
|
|
163
|
+
return {"ok": False, "error": f"HTTP {e.code}: {detail[:400]}"}
|
|
164
|
+
challenge_raw = e.read().decode() if e.fp else "{}"
|
|
165
|
+
except Exception as e:
|
|
166
|
+
return {"ok": False, "error": f"Ledger API unreachable: {e}"}
|
|
167
|
+
|
|
168
|
+
# 402 — pay and retry
|
|
169
|
+
try:
|
|
170
|
+
challenge = json.loads(challenge_raw)
|
|
171
|
+
accept = (challenge.get("accepts") or [{}])[0]
|
|
172
|
+
price = accept.get("amount") or accept.get("maxAmountRequired")
|
|
173
|
+
price_usd = f"${int(price) / 1_000_000:.4f}" if price else "?"
|
|
174
|
+
except Exception:
|
|
175
|
+
accept, price_usd = {}, "?"
|
|
176
|
+
|
|
177
|
+
if not PRIVATE_KEY:
|
|
178
|
+
return {
|
|
179
|
+
"ok": False,
|
|
180
|
+
"needs_payment": True,
|
|
181
|
+
"error": (
|
|
182
|
+
f"This Ledger endpoint costs {price_usd} per call (x402, USDC on Base). "
|
|
183
|
+
"Set LEDGER_X402_PRIVATE_KEY to a funded Base wallet private key to pay "
|
|
184
|
+
"automatically, or pay the x402 challenge yourself. "
|
|
185
|
+
"Free/keyless: health() and discover()."
|
|
186
|
+
),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
header = _eip3009_header(accept)
|
|
191
|
+
except PaymentError as e:
|
|
192
|
+
return {"ok": False, "error": f"Could not build payment: {e}"}
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
with _do(header) as resp:
|
|
196
|
+
return {"ok": True, "data": json.loads(resp.read() or "null"), "paid": price_usd}
|
|
197
|
+
except urllib.error.HTTPError as e:
|
|
198
|
+
detail = e.read().decode() if e.fp else ""
|
|
199
|
+
return {"ok": False, "error": f"Payment rejected — HTTP {e.code}: {detail[:400]}"}
|
|
200
|
+
except Exception as e:
|
|
201
|
+
return {"ok": False, "error": f"Ledger API unreachable after payment: {e}"}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _out(result: dict) -> str:
|
|
205
|
+
"""Render an API result as compact, agent-readable text."""
|
|
206
|
+
if not result.get("ok"):
|
|
207
|
+
return f"Error: {result.get('error', 'unknown')}"
|
|
208
|
+
data = result.get("data")
|
|
209
|
+
paid = f" (paid {result['paid']})" if result.get("paid") else ""
|
|
210
|
+
return f"{json.dumps(data, indent=2)}{paid}"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# --- MCP Tools ---
|
|
214
|
+
|
|
215
|
+
@mcp.tool()
|
|
216
|
+
def health() -> str:
|
|
217
|
+
"""Check the Ledger API status (free, no payment). Returns service version,
|
|
218
|
+
whether x402 payment is enabled, and the capability-token public key."""
|
|
219
|
+
return _out(_request("GET", "/health"))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@mcp.tool()
|
|
223
|
+
def discover() -> str:
|
|
224
|
+
"""List the paid Ledger endpoints and their x402 prices (free, no payment).
|
|
225
|
+
Reads the x402 discovery document — useful before spending."""
|
|
226
|
+
return _out(_request("GET", "/.well-known/x402"))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@mcp.tool()
|
|
230
|
+
def create_account(name: str, type: str, parent_id: int = 0) -> str:
|
|
231
|
+
"""Create an account in the chart of accounts. (Paid: write.)
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
name: Account name, e.g. "Cash", "Sales Revenue", "Accounts Payable".
|
|
235
|
+
type: One of asset | liability | equity | revenue | expense.
|
|
236
|
+
parent_id: Optional parent account id for a sub-account (0 = top level).
|
|
237
|
+
"""
|
|
238
|
+
return _out(_request("POST", "/ledger/accounts",
|
|
239
|
+
body={"name": name, "type": type, "parent_id": parent_id}))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@mcp.tool()
|
|
243
|
+
def list_accounts(include_inactive: bool = False) -> str:
|
|
244
|
+
"""List all accounts in the chart of accounts with current balances. (Paid: read.)
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
include_inactive: Also include deactivated accounts.
|
|
248
|
+
"""
|
|
249
|
+
return _out(_request("GET", "/ledger/accounts",
|
|
250
|
+
query={"include_inactive": str(include_inactive).lower()}))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@mcp.tool()
|
|
254
|
+
def get_account(account_id: int, as_of: str | None = None) -> str:
|
|
255
|
+
"""Get one account and its balance, optionally as of a date. (Paid: read.)
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
account_id: The account id.
|
|
259
|
+
as_of: Balance as of this date (YYYY-MM-DD); omit for current.
|
|
260
|
+
"""
|
|
261
|
+
return _out(_request("GET", f"/ledger/accounts/{account_id}", query={"as_of": as_of}))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@mcp.tool()
|
|
265
|
+
def post_transaction(date: str, entries: list[dict], description: str = "",
|
|
266
|
+
reference: str | None = None) -> str:
|
|
267
|
+
"""Record a balanced double-entry journal transaction. (Paid: write.)
|
|
268
|
+
|
|
269
|
+
Every transaction is two or more entries whose signed amounts sum to ZERO.
|
|
270
|
+
Amounts are integers in MINOR units (cents): a debit is POSITIVE, a credit
|
|
271
|
+
is NEGATIVE. Example — record a $500 cash sale (debit Cash, credit Revenue):
|
|
272
|
+
entries = [
|
|
273
|
+
{"account_id": 1, "amount": 50000, "memo": "cash in"},
|
|
274
|
+
{"account_id": 2, "amount": -50000, "memo": "sales revenue"}
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
date: Transaction date, YYYY-MM-DD.
|
|
279
|
+
entries: List of {"account_id": int, "amount": int (cents, +debit/-credit),
|
|
280
|
+
"memo": optional str}. Must have >=2 entries summing to 0.
|
|
281
|
+
description: Human-readable description of the transaction.
|
|
282
|
+
reference: Optional external reference (invoice #, etc.).
|
|
283
|
+
"""
|
|
284
|
+
total = sum(int(e.get("amount", 0)) for e in entries)
|
|
285
|
+
if total != 0:
|
|
286
|
+
return (f"Error: entries must sum to zero (double-entry); they sum to {total} "
|
|
287
|
+
f"cents. Adjust the amounts so debits (+) and credits (-) balance.")
|
|
288
|
+
body = {"date": date, "description": description, "entries": entries}
|
|
289
|
+
if reference is not None:
|
|
290
|
+
body["reference"] = reference
|
|
291
|
+
return _out(_request("POST", "/ledger/transactions", body=body))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@mcp.tool()
|
|
295
|
+
def list_transactions(from_date: str | None = None, to_date: str | None = None,
|
|
296
|
+
limit: int = 50, cursor: int = 0) -> str:
|
|
297
|
+
"""List journal transactions, newest first. (Paid: read.)
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
from_date: Only transactions on/after this date (YYYY-MM-DD).
|
|
301
|
+
to_date: Only transactions on/before this date (YYYY-MM-DD).
|
|
302
|
+
limit: Max rows (default 50).
|
|
303
|
+
cursor: Pagination cursor from a previous response.
|
|
304
|
+
"""
|
|
305
|
+
return _out(_request("GET", "/ledger/transactions",
|
|
306
|
+
query={"from": from_date, "to": to_date, "limit": limit, "cursor": cursor}))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@mcp.tool()
|
|
310
|
+
def get_transaction(tx_id: int) -> str:
|
|
311
|
+
"""Get a single transaction with all its entries. (Paid: read.)
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
tx_id: The transaction id.
|
|
315
|
+
"""
|
|
316
|
+
return _out(_request("GET", f"/ledger/transactions/{tx_id}"))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@mcp.tool()
|
|
320
|
+
def reverse_transaction(tx_id: int, reason: str = "reversal") -> str:
|
|
321
|
+
"""Post a reversing transaction that negates an existing one. (Paid: write.)
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
tx_id: The transaction id to reverse.
|
|
325
|
+
reason: Why it's being reversed (recorded on the reversing entry).
|
|
326
|
+
"""
|
|
327
|
+
return _out(_request("POST", f"/ledger/transactions/{tx_id}/reverse",
|
|
328
|
+
body={"reason": reason}))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@mcp.tool()
|
|
332
|
+
def trial_balance(as_of: str | None = None) -> str:
|
|
333
|
+
"""Trial balance: every account with its balance, plus total debits and
|
|
334
|
+
total credits (which must be equal in a balanced book). (Paid: read.)
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
as_of: Balances as of this date (YYYY-MM-DD); omit for current.
|
|
338
|
+
"""
|
|
339
|
+
return _out(_request("GET", "/ledger/reports/trial-balance", query={"as_of": as_of}))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@mcp.tool()
|
|
343
|
+
def general_ledger(from_date: str, to_date: str | None = None,
|
|
344
|
+
account_id: int | None = None) -> str:
|
|
345
|
+
"""General ledger: per-account transaction detail over a date range. (Paid: read.)
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
from_date: Start date (YYYY-MM-DD), required.
|
|
349
|
+
to_date: End date (YYYY-MM-DD); omit for through-today.
|
|
350
|
+
account_id: Restrict to one account; omit for all.
|
|
351
|
+
"""
|
|
352
|
+
return _out(_request("GET", "/ledger/reports/general-ledger",
|
|
353
|
+
query={"from": from_date, "to": to_date, "account_id": account_id}))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def main():
|
|
357
|
+
import argparse
|
|
358
|
+
parser = argparse.ArgumentParser(description="Ledger MCP Server")
|
|
359
|
+
parser.add_argument("--transport", choices=["stdio", "sse"],
|
|
360
|
+
default=os.environ.get("LEDGER_TRANSPORT", "stdio"))
|
|
361
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
362
|
+
parser.add_argument("--port", type=int, default=8384)
|
|
363
|
+
args = parser.parse_args()
|
|
364
|
+
if args.transport == "sse":
|
|
365
|
+
mcp.run(transport="sse", host=args.host, port=args.port)
|
|
366
|
+
else:
|
|
367
|
+
mcp.run(transport="stdio")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == "__main__":
|
|
371
|
+
main()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ledger-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Double-entry accounting MCP server for AI agents — create accounts, post balanced journal entries, pull trial-balance / general-ledger reports. Paid per call via x402 micropayments (USDC on Base)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Novadyne", email = "support@novadyne.ai" }]
|
|
13
|
+
keywords = ["mcp", "mcp-server", "accounting", "ledger", "double-entry", "bookkeeping", "finance", "x402", "micropayments", "agent-payments", "claude-code", "base", "usdc"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
18
|
+
"Topic :: Office/Business :: Financial :: Accounting",
|
|
19
|
+
"Framework :: FastAPI",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = ["mcp>=1.0", "eth-account>=0.11"]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
ledger-mcp = "ledger_mcp:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://ledger.novadyne.ai"
|
|
35
|
+
Repository = "https://github.com/novadyne-hq/ledger-mcp"
|
|
36
|
+
Issues = "https://github.com/novadyne-hq/ledger-mcp/issues"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://registry.modelcontextprotocol.io/schemas/server.json",
|
|
3
|
+
"name": "io.github.novadyne-hq/ledger-mcp",
|
|
4
|
+
"description": "Double-entry accounting MCP server for AI agents — create accounts, post balanced journal entries, and pull trial-balance / general-ledger reports. 11 tools. Paid per call via x402 micropayments (USDC on Base); health + discovery are free.",
|
|
5
|
+
"repository": "https://github.com/novadyne-hq/ledger-mcp",
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"packages": [
|
|
8
|
+
{
|
|
9
|
+
"registryType": "pypi",
|
|
10
|
+
"identifier": "ledger-mcp",
|
|
11
|
+
"version": "0.1.0",
|
|
12
|
+
"transport": {
|
|
13
|
+
"type": "stdio"
|
|
14
|
+
},
|
|
15
|
+
"environmentVariables": [
|
|
16
|
+
{
|
|
17
|
+
"name": "LEDGER_X402_PRIVATE_KEY",
|
|
18
|
+
"description": "Private key of a funded Base wallet (USDC) used to pay the x402 micropayment on each call. Optional — health and discovery work without it.",
|
|
19
|
+
"isSecret": true,
|
|
20
|
+
"isRequired": false
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "LEDGER_API_URL",
|
|
24
|
+
"description": "Override the Ledger API base URL (default https://ledger-api.novadyne.ai).",
|
|
25
|
+
"isRequired": false
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|