Skip to content

Commit a1067d9

Browse files
committed
fix: harden MCP authorization and tool contracts
1 parent 36db810 commit a1067d9

27 files changed

Lines changed: 3505 additions & 1580 deletions

README.md

Lines changed: 264 additions & 185 deletions
Large diffs are not rendered by default.

bun.lock

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/mcp-2026-07-28-upgrade.md

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
# MCP 2026-07-28 Upgrade
22

3-
Branch: `ccbbccbb/mcp-2026-07-28-upgrade`
4-
53
This repository targets the final MCP `2026-07-28` specification through the released TypeScript SDK v2 packages. The release-candidate compatibility adapter has been removed.
64

75
## Authoritative Sources
@@ -16,6 +14,7 @@ This repository targets the final MCP `2026-07-28` specification through the rel
1614
- Replaced `@modelcontextprotocol/sdk` v1 with:
1715
- `@modelcontextprotocol/server` v2
1816
- `@modelcontextprotocol/node` v2
17+
- `@modelcontextprotocol/express` v2
1918
- Zod v4.2 or newer
2019
- Replaced the local JSON-RPC adapter with SDK-native `createMcpHandler` and `serveStdio`.
2120
- Kept stdio dual-era:
@@ -30,9 +29,28 @@ This repository targets the final MCP `2026-07-28` specification through the rel
3029
- `Mcp-Method`
3130
- `Mcp-Name`
3231
- Added Host and Origin validation before the HTTP MCP handler.
32+
- Added MCP OAuth resource-server support for HTTP:
33+
- localhost can run without authorization
34+
- non-local binds fail closed unless OAuth is configured
35+
- authorization-server metadata discovery and RFC 7662 token introspection
36+
- baseline `mcp`, wallet-write `evm:write`, and signing `evm:sign` scopes
3337
- Configured all static list capabilities with `listChanged: false`; resource subscriptions remain disabled.
34-
- Configured one-hour public cache hints for discovery, list operations, resource templates, and resource reads.
38+
- Configured one-hour public cache hints for discovery, static list operations, resource templates, and the public `evm://networks` resource. Unannotated future resources retain conservative cache defaults.
3539
- Migrated MCP-bound schemas to Zod 4 object schemas so the SDK emits JSON Schema 2020-12.
40+
- Added an `outputSchema` to all 25 tools. Every successful tool call returns equivalent JSON in both `structuredContent` and a pretty-printed text content block; bigint values are represented as decimal strings.
41+
- Added native MCP multi-round-trip confirmation to the six wallet-backed operations:
42+
- `write_contract`
43+
- `transfer_native`
44+
- `transfer_erc20`
45+
- `approve_token_spending`
46+
- `sign_message`
47+
- `sign_typed_data`
48+
- Integrity-protected confirmation continuation state with the SDK HMAC codec:
49+
- binds the complete tool arguments and current HTTP bearer token
50+
- expires after five minutes
51+
- is consumed once per process before wallet access
52+
- rejects tampering, argument changes, cross-token use, and replay
53+
- Bounded `wait_for_transaction` with `timeoutSeconds` from 1 through 90, defaulting to 90 seconds so it returns before the 120-second HTTP transport timeout.
3654
- Kept process diagnostics on `stderr`, including the npm CLI startup line, so stdio `stdout` contains protocol messages only.
3755

3856
## Final-Spec Differences from the RC
@@ -47,11 +65,35 @@ The repository no longer carries RC behavior for the following changes:
4765

4866
## Compatibility Decisions
4967

50-
- HTTP remains strict `2026-07-28` to preserve the RC branch's modern-only deployment decision.
68+
- HTTP remains strict `2026-07-28` to preserve the existing modern-only deployment decision.
5169
- Stdio serves both modern and legacy clients because local hosts commonly require gradual negotiation.
5270
- The static tool, prompt, and resource surfaces do not advertise change notifications.
53-
- `wait_for_transaction` remains a normal synchronous tool. The Tasks extension is not advertised.
54-
- The server does not implement MCP OAuth. It uses environment-configured RPC and wallet credentials.
71+
- `wait_for_transaction` remains a bounded synchronous tool. The Tasks extension is not advertised because the released v2 SDK removed its experimental server runtime; the extension currently has no supported TypeScript runtime integration to adopt.
72+
- Wallet-backed operations are not executed until the client accepts the tool's MCP `input_required` confirmation. Prompts and server instructions do not request a second conversational confirmation.
73+
- OAuth is HTTP-only. Stdio continues to obtain wallet and RPC credentials from its environment.
74+
75+
## HTTP OAuth Configuration
76+
77+
The HTTP process acts as an OAuth resource server; it does not issue access tokens. With the default local `MCP_HOST=127.0.0.1`, omitting `MCP_OAUTH_ISSUER_URL` keeps OAuth disabled. Setting it enables OAuth locally. Binding to a non-local interface requires OAuth and aborts startup if the configuration is incomplete.
78+
79+
Required when OAuth is enabled:
80+
81+
- `MCP_OAUTH_ISSUER_URL`: exact HTTPS authorization-server issuer without a query or fragment
82+
- `MCP_PUBLIC_URL`: exact externally reachable MCP endpoint with the `/mcp` path and no query or fragment; non-local deployments require HTTPS
83+
- `MCP_OAUTH_CLIENT_ID`: RFC 7662 introspection client ID
84+
- `MCP_OAUTH_CLIENT_SECRET`: RFC 7662 introspection client secret
85+
86+
Optional overrides:
87+
88+
- `MCP_OAUTH_METADATA_URL`: authorization-server metadata URL; defaults to the RFC 8414 URL derived from the issuer, including correct well-known path insertion for issuers with a path
89+
- `MCP_OAUTH_INTROSPECTION_URL`: introspection endpoint when metadata does not publish `introspection_endpoint`
90+
- `MCP_OAUTH_AUDIENCE`: expected token audience/resource; defaults to `MCP_PUBLIC_URL`
91+
- `MCP_OAUTH_SCOPES`: additional advertised scopes; the minimal built-in `mcp` scope is always advertised
92+
- `MCP_OAUTH_REQUIRED_SCOPES`: additional scopes required for every MCP request; the baseline `mcp` scope is always required
93+
94+
Introspection must return an active token with a client identity, expiration, the expected audience/resource, and appropriate scopes. In addition to the baseline scope, transaction and approval tools require `evm:write`; signing tools require `evm:sign`.
95+
96+
Authorization-server metadata must support the authorization-code response type and PKCE `S256`. The issuer, metadata URL, and every authorization-server endpoint must use HTTPS. Metadata discovery and introspection reject redirects and use a 10-second deadline.
5597

5698
## Verification
5799

@@ -60,9 +102,13 @@ The automated MCP integration tests cover:
60102
- final `server/discover` shape and server identity metadata
61103
- optional `clientInfo`
62104
- deterministic tool listing and closed no-argument schemas
105+
- output schemas and structured tool results
106+
- confirmation requests and declines for all six wallet-backed operations, plus shared-helper coverage for argument binding, tamper rejection, and single-use replay prevention
107+
- bounded transaction waiting
63108
- cache hints on discovery, list, and resource results
64109
- resource reads and a read-only tool call
65110
- final `HeaderMismatch` and `UnsupportedProtocolVersion` error codes
111+
- local authorization opt-out, remote fail-closed behavior, OAuth metadata validation, RFC 7662 introspection, audience checks, and scopes
66112

67113
Release checks:
68114

@@ -77,6 +123,5 @@ bun run build:http
77123

78124
These are enhancements, not compliance blockers:
79125

80-
- Add `outputSchema` and `structuredContent` to high-value read tools while preserving text content for older clients.
81-
- Adopt the Tasks extension only if transaction confirmation regularly exceeds practical request timeouts.
82-
- Add MCP OAuth before exposing wallet-backed write tools through a shared remote deployment.
126+
- Revisit the Tasks extension only after the official SDK provides a released server runtime for the final extension protocol.
127+
- Add deployment-specific rate limiting, audit logging, secret management, and authorization-server operational guidance before hosting a shared production endpoint.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
{
22
"name": "@mcpdotdirect/evm-mcp-server",
3-
"module": "src/index.ts",
43
"type": "module",
54
"version": "2.0.4",
6-
"description": "MCP server for interacting with EVM-compatible blockchains - supports 25 tools and 10 prompts across 60+ networks",
5+
"description": "MCP server with 25 tools and 10 prompts across 55 EVM chains",
76
"bin": {
87
"evm-mcp-server": "./bin/cli.js"
98
},
@@ -29,7 +28,7 @@
2928
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
3029
"changelog:latest": "conventional-changelog -p angular -r 1 > RELEASE_NOTES.md",
3130
"inspect": "npx @modelcontextprotocol/inspector node build/index.js",
32-
"test:mcp": "bun test test/mcp-2026.test.ts"
31+
"test:mcp": "bun test test/mcp-2026.test.ts test/auth.test.ts test/http-auth.test.ts"
3332
},
3433
"devDependencies": {
3534
"@types/bun": "latest",
@@ -41,6 +40,7 @@
4140
"typescript": "^5.8.2"
4241
},
4342
"dependencies": {
43+
"@modelcontextprotocol/express": "^2.0.0",
4444
"@modelcontextprotocol/node": "^2.0.0",
4545
"@modelcontextprotocol/server": "^2.0.0",
4646
"express": "^5.2.1",

src/core/chains.ts

Lines changed: 29 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ import {
6161
} from 'viem/chains';
6262

6363
// Default configuration values
64-
export const DEFAULT_RPC_URL = 'https://eth.llamarpc.com';
6564
export const DEFAULT_CHAIN_ID = 1;
6665

6766
// Map chain IDs to chains
@@ -288,49 +287,34 @@ export const rpcUrlMap: Record<number, string> = {
288287
* @returns The resolved chain ID
289288
*/
290289
export function resolveChainId(chainIdentifier: number | string): number {
291-
if (typeof chainIdentifier === 'number') {
290+
if (typeof chainIdentifier === 'string') {
291+
const normalizedIdentifier = chainIdentifier.toLowerCase();
292+
const namedChainId = networkNameMap[normalizedIdentifier];
293+
if (namedChainId !== undefined) {
294+
return namedChainId;
295+
}
296+
297+
if (/^\d+$/.test(normalizedIdentifier)) {
298+
const numericChainId = Number(normalizedIdentifier);
299+
if (Number.isSafeInteger(numericChainId) && chainMap[numericChainId]) {
300+
return numericChainId;
301+
}
302+
}
303+
} else if (Number.isSafeInteger(chainIdentifier) && chainMap[chainIdentifier]) {
292304
return chainIdentifier;
293305
}
294-
295-
// Convert to lowercase for case-insensitive matching
296-
const networkName = chainIdentifier.toLowerCase();
297-
298-
// Check if the network name is in our map
299-
const chainId = networkNameMap[networkName];
300-
if (chainId !== undefined) {
301-
return chainId;
302-
}
303-
304-
// Try parsing as a number
305-
const parsedId = parseInt(networkName);
306-
if (!isNaN(parsedId)) {
307-
return parsedId;
308-
}
309-
310-
// Default to mainnet if not found
311-
return DEFAULT_CHAIN_ID;
306+
307+
throw new Error(`Unsupported network: ${chainIdentifier}`);
312308
}
313309

314310
/**
315311
* Returns the chain configuration for the specified chain ID or network name
316312
* @param chainIdentifier Chain ID (number) or network name (string)
317313
* @returns The chain configuration
318-
* @throws Error if the network is not supported (when string is provided)
314+
* @throws Error if the network or chain ID is not supported
319315
*/
320316
export function getChain(chainIdentifier: number | string = DEFAULT_CHAIN_ID): Chain {
321-
if (typeof chainIdentifier === 'string') {
322-
const networkName = chainIdentifier.toLowerCase();
323-
// Try to get from direct network name mapping first
324-
if (networkNameMap[networkName]) {
325-
return chainMap[networkNameMap[networkName]] || mainnet;
326-
}
327-
328-
// If not found, throw an error
329-
throw new Error(`Unsupported network: ${chainIdentifier}`);
330-
}
331-
332-
// If it's a number, return the chain from chainMap
333-
return chainMap[chainIdentifier] || mainnet;
317+
return chainMap[resolveChainId(chainIdentifier)];
334318
}
335319

336320
/**
@@ -339,19 +323,21 @@ export function getChain(chainIdentifier: number | string = DEFAULT_CHAIN_ID): C
339323
* @returns The RPC URL for the specified chain
340324
*/
341325
export function getRpcUrl(chainIdentifier: number | string = DEFAULT_CHAIN_ID): string {
342-
const chainId = typeof chainIdentifier === 'string'
343-
? resolveChainId(chainIdentifier)
344-
: chainIdentifier;
345-
346-
return rpcUrlMap[chainId] || DEFAULT_RPC_URL;
326+
return rpcUrlMap[resolveChainId(chainIdentifier)];
347327
}
348328

349329
/**
350-
* Get a list of supported networks
351-
* @returns Array of supported network names (excluding short aliases)
330+
* Get the configured network names and aliases.
331+
* @returns Array of supported network names and aliases
352332
*/
353333
export function getSupportedNetworks(): string[] {
354334
return Object.keys(networkNameMap)
355-
.filter(name => name.length > 2) // Filter out short aliases
356335
.sort();
357-
}
336+
}
337+
338+
/**
339+
* Get the number of distinct configured chain IDs.
340+
*/
341+
export function getSupportedChainCount(): number {
342+
return Object.keys(chainMap).length;
343+
}

0 commit comments

Comments
 (0)