diff --git a/src/__tests__/prefixed-resource-names.test.ts b/src/__tests__/prefixed-resource-names.test.ts new file mode 100644 index 0000000..1962489 --- /dev/null +++ b/src/__tests__/prefixed-resource-names.test.ts @@ -0,0 +1,356 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RadiusMcpSdk } from '../radius-mcp-sdk.js'; +import type { RadiusConfig, EVMAuthProof } from '../types/index.js'; +import { recoverTypedDataAddress } from 'viem'; + +// Mock viem +vi.mock('viem', () => ({ + createPublicClient: vi.fn(() => ({ + readContract: vi.fn(), + })), + getContract: vi.fn(() => ({ + read: { + balanceOf: vi.fn(), + balanceOfBatch: vi.fn(), + }, + })), + http: vi.fn(), + isAddress: vi.fn((addr: string) => /^0x[0-9a-f]{40}$/i.test(addr)), + recoverTypedDataAddress: vi.fn(), +})); + +const mockRecoverTypedDataAddress = recoverTypedDataAddress as ReturnType; + +describe('Prefixed Resource Names', () => { + let sdk: RadiusMcpSdk; + let mockBalanceOf: ReturnType; + + const config: RadiusConfig = { + contractAddress: '0x1234567890123456789012345678901234567890', + chainId: 1, + rpcUrl: 'https://ethereum.rpc.com', + }; + + beforeEach(() => { + vi.clearAllMocks(); + sdk = new RadiusMcpSdk(config); + + // Setup mocks + const mockContract = (sdk as any).contract; + mockBalanceOf = mockContract.read.balanceOf = vi.fn(); + mockBalanceOf.mockResolvedValue(1n); // User owns the token + }); + + describe('Authentication with Prefixed Names', () => { + it('should authenticate successfully with ngrok weather prefix', async () => { + const handler = vi.fn().mockResolvedValue({ result: 'success' }); + const protectedHandler = sdk.protect(101, handler); + + // Create a proof for 'get-alerts' tool with 'ngrok weather:' prefix + const proof: EVMAuthProof = { + challenge: { + domain: { + name: 'EVMAuth', + version: '1', + chainId: 1, + verifyingContract: '0x1234567890123456789012345678901234567890', + }, + primaryType: 'EVMAuthRequest', + types: { + EIP712Domain: [], + EVMAuthRequest: [], + }, + message: { + serverName: 'test-server', + resourceName: 'ngrok weather:get-alerts', // Prefixed resource name + requiredTokens: '[101]', + walletAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + nonce: `${Date.now()}-${'a'.repeat(32)}`, + issuedAt: Date.now().toString(), + expiresAt: (Date.now() + 30000).toString(), + purpose: 'test', + }, + }, + signature: '0x' + 'a'.repeat(130), + }; + + // Mock signature recovery + mockRecoverTypedDataAddress.mockResolvedValue('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'); + + // Request also uses the prefixed name + const result = await protectedHandler({ + params: { + name: 'ngrok weather:get-alerts', // Client sends prefixed name + arguments: { + __evmauth: proof, + data: 'test', + }, + }, + }); + + expect(handler).toHaveBeenCalled(); + expect(result).toEqual({ result: 'success' }); + }); + + it('should authenticate when only request has prefix', async () => { + const handler = vi.fn().mockResolvedValue({ result: 'success' }); + const protectedHandler = sdk.protect(101, handler); + + // Proof uses non-prefixed name + const proof: EVMAuthProof = { + challenge: { + domain: { + name: 'EVMAuth', + version: '1', + chainId: 1, + verifyingContract: '0x1234567890123456789012345678901234567890', + }, + primaryType: 'EVMAuthRequest', + types: { + EIP712Domain: [], + EVMAuthRequest: [], + }, + message: { + serverName: 'test-server', + resourceName: 'get-alerts', // Non-prefixed in proof + requiredTokens: '[101]', + walletAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + nonce: `${Date.now()}-${'a'.repeat(32)}`, + issuedAt: Date.now().toString(), + expiresAt: (Date.now() + 30000).toString(), + purpose: 'test', + }, + }, + signature: '0x' + 'a'.repeat(130), + }; + + mockRecoverTypedDataAddress.mockResolvedValue('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'); + + // Request uses prefixed name + const result = await protectedHandler({ + params: { + name: 'client-prefix:get-alerts', // Prefixed in request + arguments: { + __evmauth: proof, + data: 'test', + }, + }, + }); + + expect(handler).toHaveBeenCalled(); + expect(result).toEqual({ result: 'success' }); + }); + + it('should authenticate when only proof has prefix', async () => { + const handler = vi.fn().mockResolvedValue({ result: 'success' }); + const protectedHandler = sdk.protect(101, handler); + + // Proof uses prefixed name + const proof: EVMAuthProof = { + challenge: { + domain: { + name: 'EVMAuth', + version: '1', + chainId: 1, + verifyingContract: '0x1234567890123456789012345678901234567890', + }, + primaryType: 'EVMAuthRequest', + types: { + EIP712Domain: [], + EVMAuthRequest: [], + }, + message: { + serverName: 'test-server', + resourceName: 'claude-web:fetch-data', // Prefixed in proof + requiredTokens: '[101]', + walletAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + nonce: `${Date.now()}-${'a'.repeat(32)}`, + issuedAt: Date.now().toString(), + expiresAt: (Date.now() + 30000).toString(), + purpose: 'test', + }, + }, + signature: '0x' + 'a'.repeat(130), + }; + + mockRecoverTypedDataAddress.mockResolvedValue('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'); + + // Request uses non-prefixed name + const result = await protectedHandler({ + params: { + name: 'fetch-data', // Non-prefixed in request + arguments: { + __evmauth: proof, + data: 'test', + }, + }, + }); + + expect(handler).toHaveBeenCalled(); + expect(result).toEqual({ result: 'success' }); + }); + + it('should fail when tool names do not match after extraction', async () => { + const handler = vi.fn(); + const protectedHandler = sdk.protect(101, handler); + + // Proof for different tool + const proof: EVMAuthProof = { + challenge: { + domain: { + name: 'EVMAuth', + version: '1', + chainId: 1, + verifyingContract: '0x1234567890123456789012345678901234567890', + }, + primaryType: 'EVMAuthRequest', + types: { + EIP712Domain: [], + EVMAuthRequest: [], + }, + message: { + serverName: 'test-server', + resourceName: 'client:different-tool', // Different tool + requiredTokens: '[101]', + walletAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + nonce: `${Date.now()}-${'a'.repeat(32)}`, + issuedAt: Date.now().toString(), + expiresAt: (Date.now() + 30000).toString(), + purpose: 'test', + }, + }, + signature: '0x' + 'a'.repeat(130), + }; + + mockRecoverTypedDataAddress.mockResolvedValue('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'); + + const result = await protectedHandler({ + params: { + name: 'client:another-tool', // Different tool name + arguments: { + __evmauth: proof, + data: 'test', + }, + }, + }); + + expect(handler).not.toHaveBeenCalled(); + const errorText = (result as any).content[0].text; + expect(errorText).toContain('PROOF_INVALID'); + }); + + it('should handle multiple colons in prefix', async () => { + const handler = vi.fn().mockResolvedValue({ result: 'success' }); + const protectedHandler = sdk.protect(101, handler); + + const proof: EVMAuthProof = { + challenge: { + domain: { + name: 'EVMAuth', + version: '1', + chainId: 1, + verifyingContract: '0x1234567890123456789012345678901234567890', + }, + primaryType: 'EVMAuthRequest', + types: { + EIP712Domain: [], + EVMAuthRequest: [], + }, + message: { + serverName: 'test-server', + resourceName: 'org:department:service:api-method', // Multiple colons + requiredTokens: '[101]', + walletAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + nonce: `${Date.now()}-${'a'.repeat(32)}`, + issuedAt: Date.now().toString(), + expiresAt: (Date.now() + 30000).toString(), + purpose: 'test', + }, + }, + signature: '0x' + 'a'.repeat(130), + }; + + mockRecoverTypedDataAddress.mockResolvedValue('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'); + + const result = await protectedHandler({ + params: { + name: 'another:prefix:api-method', // Different prefix, same tool + arguments: { + __evmauth: proof, + data: 'test', + }, + }, + }); + + expect(handler).toHaveBeenCalled(); + expect(result).toEqual({ result: 'success' }); + }); + }); + + describe('Backward Compatibility', () => { + it('should work with non-prefixed names', async () => { + const handler = vi.fn().mockResolvedValue({ result: 'success' }); + const protectedHandler = sdk.protect(101, handler); + + const proof: EVMAuthProof = { + challenge: { + domain: { + name: 'EVMAuth', + version: '1', + chainId: 1, + verifyingContract: '0x1234567890123456789012345678901234567890', + }, + primaryType: 'EVMAuthRequest', + types: { + EIP712Domain: [], + EVMAuthRequest: [], + }, + message: { + serverName: 'test-server', + resourceName: 'simple-tool', // No prefix + requiredTokens: '[101]', + walletAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + nonce: `${Date.now()}-${'a'.repeat(32)}`, + issuedAt: Date.now().toString(), + expiresAt: (Date.now() + 30000).toString(), + purpose: 'test', + }, + }, + signature: '0x' + 'a'.repeat(130), + }; + + mockRecoverTypedDataAddress.mockResolvedValue('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'); + + const result = await protectedHandler({ + params: { + name: 'simple-tool', // No prefix + arguments: { + __evmauth: proof, + data: 'test', + }, + }, + }); + + expect(handler).toHaveBeenCalled(); + expect(result).toEqual({ result: 'success' }); + }); + + it('should handle empty or missing tool names gracefully', async () => { + const handler = vi.fn(); + const protectedHandler = sdk.protect(101, handler); + + const result = await protectedHandler({ + params: { + // name is missing + arguments: { + data: 'test', + }, + }, + }); + + expect(handler).not.toHaveBeenCalled(); + const errorText = (result as any).content[0].text; + expect(errorText).toContain('PROOF_MISSING'); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/resource-parser.test.ts b/src/__tests__/resource-parser.test.ts new file mode 100644 index 0000000..ceb4bae --- /dev/null +++ b/src/__tests__/resource-parser.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest'; +import { + extractToolName, + hasPrefix, + extractPrefix, + normalizeResourceName, + compareResourceNames, +} from '../utils/resource-parser.js'; + +describe('Resource Parser', () => { + describe('extractToolName', () => { + it('should extract tool name from prefixed resource', () => { + expect(extractToolName('ngrok weather:get-alerts')).toBe('get-alerts'); + expect(extractToolName('client:tool-name')).toBe('tool-name'); + expect(extractToolName('prefix:another-tool')).toBe('another-tool'); + }); + + it('should handle multiple colons (nested prefixes)', () => { + expect(extractToolName('client:prefix:tool')).toBe('tool'); + expect(extractToolName('a:b:c:d')).toBe('d'); + expect(extractToolName('namespace:service:method')).toBe('method'); + }); + + it('should return full name when no prefix', () => { + expect(extractToolName('simple-tool')).toBe('simple-tool'); + expect(extractToolName('get_weather')).toBe('get_weather'); + expect(extractToolName('tool')).toBe('tool'); + }); + + it('should handle edge cases', () => { + expect(extractToolName('')).toBe(''); + expect(extractToolName(undefined)).toBe(''); + expect(extractToolName(':')).toBe(''); + expect(extractToolName(':::')).toBe(''); + expect(extractToolName(':tool')).toBe('tool'); + expect(extractToolName('prefix:')).toBe(''); + }); + + it('should preserve special characters in tool names', () => { + expect(extractToolName('client:get-weather-data')).toBe('get-weather-data'); + expect(extractToolName('client:get_weather_data')).toBe('get_weather_data'); + expect(extractToolName('client:getWeatherData')).toBe('getWeatherData'); + expect(extractToolName('client:get.weather.data')).toBe('get.weather.data'); + }); + + it('should handle real-world examples from Issue #3', () => { + // Examples from the bug report + expect(extractToolName('ngrok weather:get-alerts')).toBe('get-alerts'); + expect(extractToolName('claude-web:fetch-data')).toBe('fetch-data'); + expect(extractToolName('mcp-client:process_request')).toBe('process_request'); + }); + }); + + describe('hasPrefix', () => { + it('should detect prefixed resource names', () => { + expect(hasPrefix('client:tool')).toBe(true); + expect(hasPrefix('ngrok weather:get-alerts')).toBe(true); + expect(hasPrefix('a:b:c')).toBe(true); + }); + + it('should detect non-prefixed resource names', () => { + expect(hasPrefix('simple-tool')).toBe(false); + expect(hasPrefix('get_weather')).toBe(false); + expect(hasPrefix('tool')).toBe(false); + }); + + it('should handle edge cases', () => { + expect(hasPrefix('')).toBe(false); + expect(hasPrefix(undefined)).toBe(false); + expect(hasPrefix(':')).toBe(true); + expect(hasPrefix(':::')).toBe(true); + }); + }); + + describe('extractPrefix', () => { + it('should extract single prefix', () => { + expect(extractPrefix('client:tool')).toBe('client'); + expect(extractPrefix('prefix:another-tool')).toBe('prefix'); + }); + + it('should extract compound prefix', () => { + expect(extractPrefix('ngrok weather:get-alerts')).toBe('ngrok weather'); + expect(extractPrefix('client:prefix:tool')).toBe('client:prefix'); + expect(extractPrefix('a:b:c:d')).toBe('a:b:c'); + }); + + it('should return empty string for non-prefixed names', () => { + expect(extractPrefix('simple-tool')).toBe(''); + expect(extractPrefix('tool')).toBe(''); + }); + + it('should handle edge cases', () => { + expect(extractPrefix('')).toBe(''); + expect(extractPrefix(undefined)).toBe(''); + expect(extractPrefix(':')).toBe(''); + expect(extractPrefix(':tool')).toBe(''); + expect(extractPrefix('prefix:')).toBe('prefix'); + }); + }); + + describe('normalizeResourceName', () => { + it('should normalize prefixed names', () => { + expect(normalizeResourceName('client:tool-name')).toBe('tool-name'); + expect(normalizeResourceName('CLIENT:TOOL-NAME')).toBe('TOOL-NAME'); + expect(normalizeResourceName(' client:tool ')).toBe('tool'); + }); + + it('should normalize non-prefixed names', () => { + expect(normalizeResourceName('tool-name')).toBe('tool-name'); + expect(normalizeResourceName(' tool-name ')).toBe('tool-name'); + expect(normalizeResourceName('TOOL-NAME')).toBe('TOOL-NAME'); + }); + + it('should handle edge cases', () => { + expect(normalizeResourceName('')).toBe(''); + expect(normalizeResourceName(undefined)).toBe(''); + expect(normalizeResourceName(' ')).toBe(''); + expect(normalizeResourceName(':')).toBe(''); + }); + }); + + describe('compareResourceNames', () => { + it('should match prefixed and non-prefixed names', () => { + expect(compareResourceNames('client:tool', 'tool')).toBe(true); + expect(compareResourceNames('ngrok weather:get-alerts', 'get-alerts')).toBe(true); + expect(compareResourceNames('prefix:tool', 'another-prefix:tool')).toBe(true); + }); + + it('should match identical names', () => { + expect(compareResourceNames('tool', 'tool')).toBe(true); + expect(compareResourceNames('client:tool', 'client:tool')).toBe(true); + expect(compareResourceNames('get-alerts', 'get-alerts')).toBe(true); + }); + + it('should handle whitespace', () => { + expect(compareResourceNames(' client:tool ', 'tool')).toBe(true); + expect(compareResourceNames('tool ', ' tool')).toBe(true); + expect(compareResourceNames('client:tool ', ' another:tool ')).toBe(true); + }); + + it('should not match different tool names', () => { + expect(compareResourceNames('client:tool1', 'tool2')).toBe(false); + expect(compareResourceNames('tool1', 'tool2')).toBe(false); + expect(compareResourceNames('client:tool1', 'client:tool2')).toBe(false); + }); + + it('should handle edge cases', () => { + expect(compareResourceNames('', '')).toBe(true); + expect(compareResourceNames(undefined, undefined)).toBe(true); + expect(compareResourceNames('tool', undefined)).toBe(false); + expect(compareResourceNames(undefined, 'tool')).toBe(false); + expect(compareResourceNames(':', ':')).toBe(true); + }); + + it('should handle real-world scenarios from Issue #3', () => { + // The bug scenario: tool name extraction uses full name, proof uses extracted name + const clientResourceName = 'ngrok weather:get-alerts'; + const extractedFromProof = 'get-alerts'; + const fullNameFromParams = 'ngrok weather:get-alerts'; + + // All these should match + expect(compareResourceNames(clientResourceName, extractedFromProof)).toBe(true); + expect(compareResourceNames(fullNameFromParams, extractedFromProof)).toBe(true); + expect(compareResourceNames(clientResourceName, fullNameFromParams)).toBe(true); + }); + }); + + describe('Integration Scenarios', () => { + it('should handle Claude Web naming pattern', () => { + const claudeWebResource = 'claude-web:fetch-user-data'; + const toolName = extractToolName(claudeWebResource); + + expect(toolName).toBe('fetch-user-data'); + expect(hasPrefix(claudeWebResource)).toBe(true); + expect(extractPrefix(claudeWebResource)).toBe('claude-web'); + expect(compareResourceNames(claudeWebResource, 'fetch-user-data')).toBe(true); + }); + + it('should handle ngrok proxy pattern', () => { + const ngrokResource = 'ngrok weather:get-forecast'; + const toolName = extractToolName(ngrokResource); + + expect(toolName).toBe('get-forecast'); + expect(hasPrefix(ngrokResource)).toBe(true); + expect(extractPrefix(ngrokResource)).toBe('ngrok weather'); + expect(compareResourceNames(ngrokResource, 'get-forecast')).toBe(true); + }); + + it('should handle custom MCP client patterns', () => { + const patterns = [ + 'mcp-client:tool', + 'custom.client:api.method', + 'namespace:service:endpoint', + 'org.company.product:feature:action', + ]; + + const expectedTools = [ + 'tool', + 'api.method', + 'endpoint', + 'action', + ]; + + patterns.forEach((pattern, index) => { + expect(extractToolName(pattern)).toBe(expectedTools[index]); + expect(hasPrefix(pattern)).toBe(true); + }); + }); + + it('should maintain backward compatibility with non-prefixed names', () => { + const legacyNames = [ + 'get-weather', + 'fetch_data', + 'processRequest', + 'simple-tool', + ]; + + legacyNames.forEach(name => { + expect(extractToolName(name)).toBe(name); + expect(hasPrefix(name)).toBe(false); + expect(extractPrefix(name)).toBe(''); + expect(compareResourceNames(name, name)).toBe(true); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/radius-mcp-sdk.ts b/src/radius-mcp-sdk.ts index c141581..1b0f59e 100644 --- a/src/radius-mcp-sdk.ts +++ b/src/radius-mcp-sdk.ts @@ -12,6 +12,7 @@ import { } from 'viem'; import type { CacheConfig, RadiusConfig, MCPHandler, MCPRequest, MCPResponse, EVMAuthErrorResponse, EVMAuthProof, ProofErrorCode } from './types/index.js'; import { RadiusError } from './types/errors.js'; +import { extractToolName } from './utils/resource-parser.js'; const ERC1155_ABI = [ { @@ -165,7 +166,8 @@ export class RadiusMcpSdk { } const params = request?.params as { name?: string; arguments?: Record }; - const toolName = params?.name || 'unknown_tool'; + // Extract the actual tool name from potentially prefixed resource names + const toolName = extractToolName(params?.name) || 'unknown_tool'; try { if (this.config.debug) { @@ -433,9 +435,8 @@ export class RadiusMcpSdk { this.validateNonce(message.nonce); - const resourceToolName = message.resourceName.includes(':') - ? message.resourceName.split(':').pop() || message.resourceName - : message.resourceName; + // Extract tool name from the resource name in the proof (handles prefixed names) + const resourceToolName = extractToolName(message.resourceName); if (resourceToolName !== toolName) { if (this.config.debug) { diff --git a/src/utils/resource-parser.ts b/src/utils/resource-parser.ts new file mode 100644 index 0000000..2998be6 --- /dev/null +++ b/src/utils/resource-parser.ts @@ -0,0 +1,110 @@ +/** + * Utility for consistent resource name parsing across the SDK + * Handles both prefixed (e.g., "client:tool-name") and non-prefixed names + */ + +/** + * Extracts the tool name from a potentially prefixed resource name + * + * @param resourceName - The resource name, potentially with prefix (e.g., "ngrok weather:get-alerts") + * @returns The extracted tool name (e.g., "get-alerts") + * + * @example + * extractToolName("ngrok weather:get-alerts") // returns "get-alerts" + * extractToolName("simple-tool") // returns "simple-tool" + * extractToolName("client:prefix:tool") // returns "tool" (handles multiple colons) + * extractToolName("") // returns "" + * extractToolName(undefined) // returns "" + */ +export function extractToolName(resourceName: string | undefined): string { + if (!resourceName) return ''; + + // If the resource name contains a colon, extract the part after the last colon + // This handles cases like "client:tool" or "client:prefix:tool" + if (resourceName.includes(':')) { + const parts = resourceName.split(':'); + const toolName = parts[parts.length - 1]; + // Return empty string if the tool name part is empty (e.g., "prefix:" or ":") + return toolName || ''; + } + + // No prefix, return as-is + return resourceName; +} + +/** + * Checks if a resource name has a prefix + * + * @param resourceName - The resource name to check + * @returns True if the resource name contains a prefix + * + * @example + * hasPrefix("ngrok weather:get-alerts") // returns true + * hasPrefix("simple-tool") // returns false + */ +export function hasPrefix(resourceName: string | undefined): boolean { + if (!resourceName) return false; + return resourceName.includes(':'); +} + +/** + * Extracts the prefix from a resource name + * + * @param resourceName - The resource name with potential prefix + * @returns The prefix part, or empty string if no prefix + * + * @example + * extractPrefix("ngrok weather:get-alerts") // returns "ngrok weather" + * extractPrefix("client:prefix:tool") // returns "client:prefix" + * extractPrefix("simple-tool") // returns "" + */ +export function extractPrefix(resourceName: string | undefined): string { + if (!resourceName || !resourceName.includes(':')) return ''; + + const parts = resourceName.split(':'); + // Remove the last part (tool name) and join the rest + parts.pop(); + return parts.join(':'); +} + +/** + * Normalizes a resource name for comparison + * Handles case sensitivity and whitespace + * + * @param resourceName - The resource name to normalize + * @returns Normalized resource name + */ +export function normalizeResourceName(resourceName: string | undefined): string { + if (!resourceName) return ''; + + // Extract tool name and normalize + const toolName = extractToolName(resourceName); + + // Trim whitespace and convert to lowercase for comparison + // Note: We preserve the original case in extraction but normalize for comparison + return toolName.trim(); +} + +/** + * Compares two resource names for equality + * Handles prefixed names and normalization + * + * @param name1 - First resource name + * @param name2 - Second resource name + * @returns True if the tool names match (ignoring prefixes) + * + * @example + * compareResourceNames("ngrok weather:get-alerts", "get-alerts") // returns true + * compareResourceNames("client:tool", "another-client:tool") // returns true + * compareResourceNames("tool", "tool") // returns true + * compareResourceNames("tool1", "tool2") // returns false + */ +export function compareResourceNames( + name1: string | undefined, + name2: string | undefined +): boolean { + const normalized1 = normalizeResourceName(name1); + const normalized2 = normalizeResourceName(name2); + + return normalized1 === normalized2; +} \ No newline at end of file