Skip to content

Commit 67e11cf

Browse files
committed
fix: enforce exact amounts and browser MCP interoperability
1 parent 48df803 commit 67e11cf

10 files changed

Lines changed: 380 additions & 101 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ The HTTP server uses the following default configuration:
270270

271271
When binding to a non-local interface, explicitly configure the public hostnames accepted by `MCP_ALLOWED_HOSTS` and `MCP_ALLOWED_ORIGINS`. Values may be hostnames or origin URLs; validation is port-agnostic.
272272

273+
Allowed browser origins receive CORS headers on responses, including OAuth challenges and discovery metadata. Preflight requests are validated before authentication; actual MCP requests still require the configured bearer scopes. CORS permits GET/POST, standard MCP headers, authorization, and annotated `Mcp-Param-*` headers. Cookies are not enabled.
274+
273275
#### HTTP OAuth
274276

275277
The HTTP process is an OAuth resource server; it does not issue access tokens. OAuth is optional only when `MCP_HOST` is local (`127.0.0.1`, `localhost`, or `::1`). If `MCP_OAUTH_ISSUER_URL` is set, OAuth is enabled even for a local bind. A non-local bind fails during startup unless OAuth is fully configured.
@@ -351,6 +353,8 @@ bun dev:http
351353

352354
Connect to this MCP server using any MCP-compatible client. For testing and debugging, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector).
353355

356+
Run `bun run inspect` for the Inspector UI, or `bun run inspect:check` for strict tool-schema checks. Both build the stdio server and use `mcp-inspector.json` with explicit modern protocol negotiation. Inspector requires Node >=22.19.0 and is pinned to the verified 2.5.0 release. To inspect HTTP, start `bun run start:http` separately and select `evm-http`; adjust the config URL if using a different port.
357+
354358
### Connecting from Cursor
355359

356360
To connect to the MCP server from Cursor:
@@ -576,6 +580,8 @@ The following wallet-backed tools enforce confirmation through MCP multi-round-t
576580

577581
The first invocation describes the exact operation and requests a boolean `confirm` input. No wallet action occurs until the client returns an accepted response with `confirm: true`; declining or cancelling terminates the operation. Clients should display this protocol-level request instead of adding a separate conversational confirmation.
578582

583+
Amounts must be non-negative decimal strings exactly representable in the asset's base units; excess nonzero decimal places are rejected before confirmation. Token precision is read before each confirmation round, and the confirmation binds both decimals and the exact base-unit amount. A precision change requires fresh confirmation. Execution uses the confirmed base units without another decimals lookup.
584+
579585
Confirmation continuation state is HMAC integrity-protected and binds the complete tool arguments. It expires after five minutes, is process-local and single-use, and is also bound to the authenticated bearer token for HTTP requests. An expired, replayed, cross-process, or differently authenticated continuation requires a new confirmation.
580586

581587
For example, the initial transfer call uses ordinary `tools/call` parameters:

mcp-inspector.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"mcpServers": {
3+
"evm-stdio": {
4+
"type": "stdio",
5+
"command": "node",
6+
"args": ["bin/cli.js"],
7+
"protocolEra": "modern"
8+
},
9+
"evm-http": {
10+
"type": "streamable-http",
11+
"url": "http://127.0.0.1:3001/mcp",
12+
"protocolEra": "modern"
13+
}
14+
}
15+
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
"release": "npm publish",
2828
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
2929
"changelog:latest": "conventional-changelog -p angular -r 1 > RELEASE_NOTES.md",
30-
"inspect": "npx @modelcontextprotocol/inspector node build/index.js",
30+
"inspect": "bun run build && npx -y @modelcontextprotocol/inspector@2.5.0 --config mcp-inspector.json",
31+
"inspect:check": "bun run build && npx -y @modelcontextprotocol/inspector@2.5.0 --cli --config mcp-inspector.json --server evm-stdio --method tools/list --strict",
3132
"typecheck": "tsc --noEmit",
3233
"test:mcp": "bun test test",
3334
"check": "bun run typecheck && bun run build && bun run build:http && bun run test:mcp"

src/core/services/transfer.ts

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {
2-
parseEther,
32
parseUnits,
43
type Address,
54
type Hash,
@@ -47,6 +46,45 @@ const erc20TransferAbi = [
4746
}
4847
] as const;
4948

49+
/** Parse a non-negative amount without rounding away fractional base units. */
50+
export function parseExactAmount(amount: string, decimals: number): bigint {
51+
if (!/^\d+(\.\d+)?$/.test(amount)) {
52+
throw new Error('Amount must be a non-negative decimal string');
53+
}
54+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) {
55+
throw new Error('Invalid token decimals');
56+
}
57+
const fraction = (amount.split('.')[1] ?? '').replace(/0+$/, '');
58+
if (fraction.length > decimals) {
59+
throw new Error(`Amount exceeds ${decimals} decimal places; rounding is not allowed`);
60+
}
61+
const raw = parseUnits(amount, decimals);
62+
if (raw >= 2n ** 256n) {
63+
throw new Error('Amount exceeds uint256');
64+
}
65+
return raw;
66+
}
67+
68+
export type TokenAmount = {
69+
raw: bigint;
70+
formatted: string;
71+
decimals: number;
72+
};
73+
74+
/** Resolve token precision before confirmation and retain the exact execution amount. */
75+
export async function prepareERC20Amount(
76+
tokenAddress: Address,
77+
amount: string,
78+
network = 'ethereum'
79+
): Promise<TokenAmount> {
80+
const decimals = await getPublicClient(network).readContract({
81+
address: tokenAddress,
82+
abi: erc20TransferAbi,
83+
functionName: 'decimals'
84+
});
85+
return { raw: parseExactAmount(amount, decimals), formatted: amount, decimals };
86+
}
87+
5088
/**
5189
* Transfer a chain's native token to an address
5290
* @param privateKey Sender's private key
@@ -70,7 +108,7 @@ export async function transferETH(
70108
: privateKey as Hex;
71109

72110
const client = getWalletClient(formattedKey, network);
73-
const amountWei = parseEther(amount);
111+
const amountWei = parseExactAmount(amount, 18);
74112

75113
return client.sendTransaction({
76114
to: toAddress,
@@ -84,15 +122,15 @@ export async function transferETH(
84122
* Transfer ERC20 tokens to an address
85123
* @param tokenAddressOrEns Token contract address or ENS name
86124
* @param toAddressOrEns Recipient address or ENS name
87-
* @param amount Amount to send (in token units)
125+
* @param amount Exact token amount prepared before confirmation
88126
* @param privateKey Sender's private key
89127
* @param network Network name or chain ID
90128
* @returns Transaction details
91129
*/
92130
export async function transferERC20(
93131
tokenAddressOrEns: string,
94132
toAddressOrEns: string,
95-
amount: string,
133+
amount: TokenAmount,
96134
privateKey: string | `0x${string}`,
97135
network: string = 'ethereum'
98136
): Promise<{
@@ -123,12 +161,12 @@ export async function transferERC20(
123161
client: publicClient,
124162
});
125163

126-
// Get token decimals and symbol
127-
const decimals = await contract.read.decimals();
164+
// Preserve the precision and base units that were confirmed.
165+
const decimals = amount.decimals;
128166
const symbol = await contract.read.symbol();
129167

130-
// Parse the amount with the correct number of decimals
131-
const rawAmount = parseUnits(amount, decimals);
168+
// Use the exact base units prepared before confirmation.
169+
const rawAmount = amount.raw;
132170

133171
// Create wallet client for sending the transaction
134172
const walletClient = getWalletClient(formattedKey, network);
@@ -147,7 +185,7 @@ export async function transferERC20(
147185
txHash: hash,
148186
amount: {
149187
raw: rawAmount,
150-
formatted: amount
188+
formatted: amount.formatted
151189
},
152190
token: {
153191
symbol,
@@ -160,15 +198,15 @@ export async function transferERC20(
160198
* Approve ERC20 token spending
161199
* @param tokenAddressOrEns Token contract address or ENS name
162200
* @param spenderAddressOrEns Spender address or ENS name
163-
* @param amount Amount to approve (in token units)
201+
* @param amount Exact token amount prepared before confirmation
164202
* @param privateKey Owner's private key
165203
* @param network Network name or chain ID
166204
* @returns Transaction hash
167205
*/
168206
export async function approveERC20(
169207
tokenAddressOrEns: string,
170208
spenderAddressOrEns: string,
171-
amount: string,
209+
amount: TokenAmount,
172210
privateKey: string | `0x${string}`,
173211
network: string = 'ethereum'
174212
): Promise<Hash> {
@@ -181,19 +219,8 @@ export async function approveERC20(
181219
? `0x${privateKey}` as `0x${string}`
182220
: privateKey as `0x${string}`;
183221

184-
// Get token details
185-
const publicClient = getPublicClient(network);
186-
const contract = getContract({
187-
address: tokenAddress,
188-
abi: erc20TransferAbi,
189-
client: publicClient,
190-
});
191-
192-
// Get token decimals
193-
const decimals = await contract.read.decimals();
194-
195-
// Parse the amount with the correct number of decimals
196-
const rawAmount = parseUnits(amount, decimals);
222+
// Use the base units bound to the accepted confirmation, without re-reading decimals.
223+
const rawAmount = amount.raw;
197224

198225
// Create wallet client for sending the transaction
199226
const walletClient = getWalletClient(formattedKey, network);

src/core/tools.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ const supportedNetworksOutputSchema = z.object({
4646
const gasPriceOutputSchema = z.object({
4747
network: z.string(),
4848
gasPricePerGas: z.string(),
49-
priorityFeePerGas: z.string().nullable(),
49+
// Branch descriptions preserve anyOf instead of Zod's compact type array.
50+
priorityFeePerGas: z.union([
51+
z.string().describe("Estimated priority fee in wei."),
52+
z.null().describe("The network does not provide a priority fee estimate.")
53+
]),
5054
currency: z.literal("wei")
5155
});
5256

@@ -1105,6 +1109,7 @@ export function registerEVMTools(server: McpServer) {
11051109
};
11061110
}
11071111

1112+
const valueWei = value === undefined ? undefined : services.parseExactAmount(value, 18);
11081113
const functionSignature = `${functionAbi.name}(${
11091114
(functionAbi.inputs ?? [])
11101115
.map((input: { type?: unknown }) => String(input.type ?? "unknown"))
@@ -1118,6 +1123,7 @@ export function registerEVMTools(server: McpServer) {
11181123
functionName,
11191124
args,
11201125
value: value ?? null,
1126+
valueWei: valueWei?.toString() ?? null,
11211127
abiJson: abiJson ?? null,
11221128
functionAbi,
11231129
network
@@ -1140,9 +1146,8 @@ export function registerEVMTools(server: McpServer) {
11401146
};
11411147

11421148
// Add value if provided (for payable functions)
1143-
if (value) {
1144-
const { parseEther } = await import('viem');
1145-
writeParams.value = parseEther(value);
1149+
if (valueWei !== undefined) {
1150+
writeParams.value = valueWei;
11461151
}
11471152

11481153
// Execute the write operation
@@ -1312,11 +1317,12 @@ export function registerEVMTools(server: McpServer) {
13121317
},
13131318
async ({ to, amount, network = "ethereum" }, ctx) => {
13141319
try {
1320+
const amountWei = services.parseExactAmount(amount, 18);
13151321
const resolvedRecipient = await services.resolveAddress(to, network);
13161322
const confirmation = await requireConfirmation(
13171323
ctx,
13181324
"transfer_native",
1319-
{ to, resolvedRecipient, amount, network },
1325+
{ to, resolvedRecipient, amount, amountWei: amountWei.toString(), network },
13201326
`Transfer ${amount} native tokens to ${to} (${resolvedRecipient}) on ${network}?`
13211327
);
13221328
if (confirmation) {
@@ -1368,6 +1374,7 @@ export function registerEVMTools(server: McpServer) {
13681374
services.resolveAddress(tokenAddress, network),
13691375
services.resolveAddress(to, network)
13701376
]);
1377+
const tokenAmount = await services.prepareERC20Amount(resolvedTokenAddress, amount, network);
13711378
const confirmation = await requireConfirmation(
13721379
ctx,
13731380
"transfer_erc20",
@@ -1377,9 +1384,11 @@ export function registerEVMTools(server: McpServer) {
13771384
to,
13781385
resolvedRecipient,
13791386
amount,
1387+
rawAmount: tokenAmount.raw.toString(),
1388+
decimals: tokenAmount.decimals,
13801389
network
13811390
},
1382-
`Transfer ${amount} of token ${tokenAddress} (${resolvedTokenAddress}) to ${to} (${resolvedRecipient}) on ${network}?`
1391+
`Transfer ${amount} of token ${tokenAddress} (${resolvedTokenAddress}) to ${to} (${resolvedRecipient}) on ${network} (${tokenAmount.raw} base units at ${tokenAmount.decimals} decimals)?`
13831392
);
13841393
if (confirmation) {
13851394
return confirmation;
@@ -1390,7 +1399,7 @@ export function registerEVMTools(server: McpServer) {
13901399
const result = await services.transferERC20(
13911400
resolvedTokenAddress,
13921401
resolvedRecipient,
1393-
amount,
1402+
tokenAmount,
13941403
privateKey,
13951404
network
13961405
);
@@ -1439,6 +1448,7 @@ export function registerEVMTools(server: McpServer) {
14391448
services.resolveAddress(tokenAddress, network),
14401449
services.resolveAddress(spenderAddress, network)
14411450
]);
1451+
const tokenAmount = await services.prepareERC20Amount(resolvedTokenAddress, amount, network);
14421452
const confirmation = await requireConfirmation(
14431453
ctx,
14441454
"approve_token_spending",
@@ -1448,9 +1458,11 @@ export function registerEVMTools(server: McpServer) {
14481458
spenderAddress,
14491459
resolvedSpenderAddress,
14501460
amount,
1461+
rawAmount: tokenAmount.raw.toString(),
1462+
decimals: tokenAmount.decimals,
14511463
network
14521464
},
1453-
`Approve ${spenderAddress} (${resolvedSpenderAddress}) to spend ${amount} of token ${tokenAddress} (${resolvedTokenAddress}) on ${network}?`
1465+
`Approve ${spenderAddress} (${resolvedSpenderAddress}) to spend ${amount} of token ${tokenAddress} (${resolvedTokenAddress}) on ${network} (${tokenAmount.raw} base units at ${tokenAmount.decimals} decimals)?`
14541466
);
14551467
if (confirmation) {
14561468
return confirmation;
@@ -1461,7 +1473,7 @@ export function registerEVMTools(server: McpServer) {
14611473
const txHash = await services.approveERC20(
14621474
resolvedTokenAddress,
14631475
resolvedSpenderAddress,
1464-
amount,
1476+
tokenAmount,
14651477
privateKey,
14661478
network
14671479
);

src/server/http-app.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,47 @@ export function createHttpApp(options: HttpAppOptions): HttpApp {
6060
}
6161
});
6262

63+
// Browser preflights do not carry bearer tokens. Validate their host/origin
64+
// before answering, and retain CORS headers on OAuth challenges and metadata.
65+
app.use((req: Request, res: Response, next: NextFunction) => {
66+
if (!validateHost(req, res) || !validateOrigin(req, res)) {
67+
return;
68+
}
69+
res.vary("Origin");
70+
const origin = req.get("Origin");
71+
if (!origin) {
72+
next();
73+
return;
74+
}
75+
res.setHeader("Access-Control-Allow-Origin", origin);
76+
res.setHeader("Access-Control-Expose-Headers", "WWW-Authenticate, Retry-After, MCP-Protocol-Version");
77+
if (req.method !== "OPTIONS") {
78+
next();
79+
return;
80+
}
81+
82+
res.vary("Access-Control-Request-Method");
83+
res.vary("Access-Control-Request-Headers");
84+
const method = req.get("Access-Control-Request-Method");
85+
const headers = (req.get("Access-Control-Request-Headers") ?? "")
86+
.split(",").map(header => header.trim().toLowerCase()).filter(Boolean);
87+
const allowedHeaders = new Set([
88+
"accept", "content-type", "authorization", "mcp-protocol-version", "mcp-method", "mcp-name"
89+
]);
90+
if (
91+
!method || !["GET", "POST"].includes(method)
92+
|| headers.some(header => !allowedHeaders.has(header) && !/^mcp-param-[a-z0-9-]+$/.test(header))
93+
) {
94+
res.status(403).end();
95+
return;
96+
}
97+
res.setHeader("Access-Control-Allow-Methods", "GET, POST");
98+
if (headers.length) {
99+
res.setHeader("Access-Control-Allow-Headers", headers.join(", "));
100+
}
101+
res.status(204).end();
102+
});
103+
63104
if (oauthConfiguration) {
64105
app.use(mcpAuthMetadataRouter({
65106
oauthMetadata: oauthConfiguration.oauthMetadata,
@@ -74,10 +115,6 @@ export function createHttpApp(options: HttpAppOptions): HttpApp {
74115
res: Response,
75116
next: NextFunction
76117
) => {
77-
if (!validateHost(req, res) || !validateOrigin(req, res)) {
78-
return;
79-
}
80-
81118
if (req.method === "POST" && !isJsonContentType(req.get("Content-Type") ?? null)) {
82119
res.status(415).json({
83120
jsonrpc: "2.0",

0 commit comments

Comments
 (0)