Skip to content

Commit abb2fb2

Browse files
author
azeth-sync[bot]
committed
v0.2.16: sync from monorepo 2026-06-04
1 parent 28942c2 commit abb2fb2

7 files changed

Lines changed: 154 additions & 14 deletions

File tree

package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@azeth/mcp-server",
3-
"version": "0.2.15",
3+
"version": "0.2.16",
44
"mcpName": "io.github.azeth-protocol/mcp-server",
55
"type": "module",
66
"description": "MCP server for the Azeth trust infrastructure — smart accounts, payments, reputation, and discovery tools for AI agents",
@@ -41,13 +41,16 @@
4141
"clean": "rm -rf dist"
4242
},
4343
"dependencies": {
44-
"@azeth/common": "^0.2.15",
45-
"@azeth/sdk": "^0.2.15",
44+
"@azeth/common": "^0.2.16",
45+
"@azeth/sdk": "^0.2.16",
4646
"@modelcontextprotocol/sdk": "^1.0.0",
4747
"dotenv": "^16.4.0",
4848
"viem": "^2.21.0",
4949
"zod": "^3.25.0"
5050
},
51+
"optionalDependencies": {
52+
"undici": "^6.21.0"
53+
},
5154
"devDependencies": {
5255
"@types/node": "^22.0.0",
5356
"typescript": "^5.7.0",

src/tools/agreements.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,8 +336,24 @@ export function registerAgreementTools(server: McpServer): void {
336336
// Receipt fetch failed — fall back to delta approach below
337337
}
338338

339-
// Enrich response with post-execution state
340-
const agreement = await client.getAgreement(agreementId, account);
339+
// Enrich response with post-execution state.
340+
// F2: guard against a read-after-write race. A load-balanced RPC node can serve
341+
// this getAgreement from a block that predates the just-mined UserOp, returning
342+
// stale executionCount / totalPaid / remainingBudget (the on-chain state is
343+
// correct; only this read lagged). When a payment was made (executionAmount > 0)
344+
// but the read's totalPaid hasn't advanced to include it, re-read with a short
345+
// backoff until the node reflects this execution — fixing all derived fields atomically.
346+
let agreement = await client.getAgreement(agreementId, account);
347+
if (executionAmount > 0n) {
348+
for (
349+
let attempt = 0;
350+
attempt < 5 && agreement.totalPaid < preExecutionTotal + executionAmount;
351+
attempt++
352+
) {
353+
await new Promise((resolve) => setTimeout(resolve, 300));
354+
agreement = await client.getAgreement(agreementId, account);
355+
}
356+
}
341357
const decimals = tokenDecimals(agreement.token, chain);
342358
const tokenSymbol = resolveTokenSymbol(agreement.token, chain);
343359
const now = BigInt(Math.floor(Date.now() / 1000));

src/tools/payments.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
66
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
77
import { AzethError, AZETH_CONTRACTS, TOKENS, formatTokenAmount } from '@azeth/common';
88
import type { AzethKit } from '@azeth/sdk';
9+
import { secureFetch, type SecureFetchGuard } from '@azeth/sdk';
910
import { createClient, resolveChain, validateAddress } from '../utils/client.js';
1011
import { resolveAddress } from '../utils/resolve.js';
1112
import { success, error, handleError, guardianRequiredError } from '../utils/response.js';
@@ -174,6 +175,66 @@ async function validateExternalUrl(urlStr: string): Promise<ValidatedUrl> {
174175
return { url: urlStr, pinnedIPv4 };
175176
}
176177

178+
/** Lazily load undici's `Agent` (an OPTIONAL dependency). undici is Node's built-in HTTP
179+
* engine, but it must be installed as a package to construct a custom connection
180+
* dispatcher. If unavailable, the connection pin is skipped — validate + redirect-refusal
181+
* still apply (graceful degradation). */
182+
let agentCtorPromise: Promise<(new (opts: unknown) => unknown) | undefined> | undefined;
183+
function loadAgentCtor(): Promise<(new (opts: unknown) => unknown) | undefined> {
184+
if (!agentCtorPromise) {
185+
// `as string` keeps this a runtime-only dynamic import so the build does not require
186+
// undici's type declarations to be present.
187+
agentCtorPromise = import('undici' as string)
188+
.then((m: { Agent?: new (opts: unknown) => unknown }) => m.Agent)
189+
.catch(() => undefined);
190+
}
191+
return agentCtorPromise;
192+
}
193+
194+
/** Build a connection dispatcher pinned to the already-validated public IPv4 address(es).
195+
* Closes the DNS-rebinding TOCTOU: after validateExternalUrl confirms the host resolves to
196+
* public IPs, the actual connection is pinned to those IPs and cannot be re-resolved to a
197+
* private target. TLS SNI / certificate validation still uses the URL hostname. Returns
198+
* undefined when there is nothing to pin (e.g. IPv6-only host) or undici is unavailable —
199+
* the request then proceeds with validate-only protection. (F9) */
200+
/** A net.LookupFunction-compatible resolver that pins DNS resolution to the given
201+
* already-validated public IPv4 address(es), ignoring the hostname. This is what makes
202+
* the connection immune to DNS rebinding: no matter what an attacker-controlled DNS
203+
* record returns at connect time, the socket only ever connects to the pre-validated IPs.
204+
* Supports both the single-address and `{ all }` callback forms. Exported for tests. (F9) */
205+
export function createPinnedLookup(
206+
pinnedIPv4: string[],
207+
): (
208+
hostname: string,
209+
opts: { all?: boolean } | undefined,
210+
cb: (err: Error | null, address: unknown, family?: number) => void,
211+
) => void {
212+
return (_hostname, opts, cb): void => {
213+
if (opts?.all) cb(null, pinnedIPv4.map((address) => ({ address, family: 4 })));
214+
else cb(null, pinnedIPv4[0], 4);
215+
};
216+
}
217+
218+
async function buildPinnedDispatcher(pinnedIPv4: string[]): Promise<unknown | undefined> {
219+
if (pinnedIPv4.length === 0) return undefined;
220+
const AgentCtor = await loadAgentCtor();
221+
if (!AgentCtor) return undefined;
222+
try {
223+
return new AgentCtor({ connect: { lookup: createPinnedLookup(pinnedIPv4) } });
224+
} catch {
225+
return undefined;
226+
}
227+
}
228+
229+
/** SSRF guard injected into the SDK's secureFetch for untrusted URLs (x402 targets,
230+
* registry-discovered endpoints): validates the URL (HTTPS, non-private/reserved IP,
231+
* DNS-resolvable) and returns a connection dispatcher pinned to the validated IPs.
232+
* Throws (AzethError) to BLOCK the request. (F9) */
233+
const secureGuard: SecureFetchGuard = async (urlStr: string) => {
234+
const validated = await validateExternalUrl(urlStr);
235+
return { dispatcher: await buildPinnedDispatcher(validated.pinnedIPv4) };
236+
};
237+
177238
/**
178239
* Apply smart account selection from the `smartAccount` tool parameter.
179240
* Accepts "#N" (1-based index from azeth_accounts) or a full address.
@@ -278,6 +339,8 @@ export function registerPaymentTools(server: McpServer): void {
278339
method: args.method,
279340
body: args.body,
280341
maxAmount,
342+
// Validate + pin every connection hop and refuse credential-carrying redirects. (F9)
343+
secureGuard,
281344
});
282345

283346
// F-5/H-1: Stream response body with size limit. Uses Uint8Array chunks
@@ -430,6 +493,10 @@ export function registerPaymentTools(server: McpServer): void {
430493
maxAmount,
431494
minReputation: args.minReputation,
432495
autoFeedback: args.autoFeedback ?? false,
496+
// SSRF guard for discovered (third-party-published) endpoints — validates + pins
497+
// every connection hop, the same protection azeth_pay applies to user URLs.
498+
// Throwing makes smartFetch402 skip that service and try the next one. (F9)
499+
secureGuard,
433500
});
434501

435502
// Stream response body with size limit (same pattern as azeth_pay)
@@ -721,10 +788,13 @@ export function registerPaymentTools(server: McpServer): void {
721788
try {
722789
client = await createClient(args.chain);
723790

724-
// Fetch the URL to get 402 response with agreement terms
725-
const response = await fetch(validated.url, {
791+
// Fetch the URL to get the 402 response with agreement terms. Untrusted URL →
792+
// validate + pin the connection and refuse redirects (no credential leakage). (F9)
793+
const response = await secureFetch(validated.url, {
726794
method: 'GET',
727795
signal: AbortSignal.timeout(15_000),
796+
guard: secureGuard,
797+
guardedRedirect: 'error',
728798
});
729799

730800
if (response.status !== 402) {

src/tools/registry.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,21 @@ async function overlayMetadataUpdates(
9797
if (raw && raw !== '0x') {
9898
const decoded = hexToString(raw);
9999
if (decoded) {
100-
entry[key] = decoded;
100+
if (key === 'capabilities') {
101+
// capabilities is a list-valued field; setMetadata stores it on-chain as a
102+
// JSON-array STRING. Parse it back to an array so the single-entry get-path
103+
// matches the list endpoint and the documented RegistryEntry.capabilities:
104+
// string[] type. On malformed input, keep the existing (server-provided)
105+
// array rather than overwrite it with a raw string. (F1: type inconsistency.)
106+
try {
107+
const parsed: unknown = JSON.parse(decoded);
108+
if (Array.isArray(parsed)) {
109+
entry[key] = parsed.filter((c): c is string => typeof c === 'string');
110+
}
111+
} catch { /* leave existing capabilities array intact */ }
112+
} else {
113+
entry[key] = decoded;
114+
}
101115
}
102116
}
103117
} catch { /* no metadata for this key */ }

src/utils/response.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,16 @@ export function guardianRequiredError(
286286
}
287287
}
288288

289-
return {
290-
content: [{ type: 'text', text: lines.join('\n') }],
291-
isError: true,
292-
};
289+
// Return through the structured error envelope (matching every other tool error)
290+
// so agents can branch on error.code. The multi-line remediation guidance is
291+
// preserved verbatim in error.suggestion. (F7: previously returned as plain text.)
292+
const [headline, ...rest] = lines;
293+
const guidance = rest.join('\n').trim();
294+
return error(
295+
'GUARDIAN_COSIGN_REQUIRED',
296+
headline ?? `Guardian co-signature required: ${reason}`,
297+
guidance.length > 0 ? guidance : undefined,
298+
);
293299
}
294300

295301
/** JSON replacer that converts bigint values to strings */

test/tools/payments.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,13 @@ describe('payment tools', () => {
189189

190190
expect(mockClient.fetch402).toHaveBeenCalledWith(
191191
'https://api.example.com/submit',
192-
{
192+
expect.objectContaining({
193193
method: 'POST',
194194
body: '{"key": "value"}',
195195
maxAmount: undefined,
196-
},
196+
// F9: azeth_pay injects an SSRF guard (validate + connection pin + redirect policy)
197+
secureGuard: expect.any(Function),
198+
}),
197199
);
198200
});
199201

test/tools/secure-guard.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { createPinnedLookup } from '../../src/tools/payments.js';
3+
4+
/** Proves the DNS-rebinding pin: the lookup returns ONLY the pre-validated public IP(s),
5+
* regardless of the hostname an attacker-controlled DNS record might (re)resolve to. */
6+
describe('createPinnedLookup (F9 DNS-rebinding pin)', () => {
7+
it('resolves to the validated pinned IP, ignoring the hostname (single-address form)', () => {
8+
const lookup = createPinnedLookup(['203.0.113.7']);
9+
let captured: { err: unknown; address: unknown; family: unknown } | undefined;
10+
lookup('attacker-rebound.example', undefined, (err, address, family) => {
11+
captured = { err, address, family };
12+
});
13+
// Even though the host "could" rebind to 169.254.x at connect time, the socket is
14+
// forced to the pre-validated public IP.
15+
expect(captured).toEqual({ err: null, address: '203.0.113.7', family: 4 });
16+
});
17+
18+
it('returns all pinned IPs in the { all } form, never an attacker-resolved address', () => {
19+
const lookup = createPinnedLookup(['203.0.113.7', '198.51.100.9']);
20+
let addresses: unknown;
21+
lookup('attacker-rebound.example', { all: true }, (_err, addr) => {
22+
addresses = addr;
23+
});
24+
expect(addresses).toEqual([
25+
{ address: '203.0.113.7', family: 4 },
26+
{ address: '198.51.100.9', family: 4 },
27+
]);
28+
});
29+
});

0 commit comments

Comments
 (0)