Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/fix-nft-resource-owner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sei-js/mcp-server': patch
---

Return the current ERC-721 owner from NFT detail resources and expose owner lookup failures.
14 changes: 6 additions & 8 deletions packages/mcp-server/src/core/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,15 +545,12 @@ export function registerEVMResources(server: McpServer) {

const nftInfo = await services.getERC721TokenMetadata(tokenAddress, tokenId, network);

// Get owner separately
let owner = 'Unknown';
let ownerError: string | undefined;
try {
const isOwner = await services.isNFTOwner(tokenAddress, params.address as Address, tokenId, network);
if (isOwner) {
owner = params.address as string;
}
} catch (_e) {
// Owner info not available
owner = await services.getERC721Owner(tokenAddress, tokenId, network);
} catch (error) {
ownerError = error instanceof Error ? error.message : String(error);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] error.message from viem readContract failures is verbose and typically embeds the request URL and raw request body. Because RPC endpoints are operator-overridable via MAINNET_RPC_URL / TESTNET_RPC_URL, this can put a private endpoint URL into a model-visible response. Consider trimming to the first line (error.message.split('\n')[0]) or a short classified message, keeping the full text on console.error. Same applies to tools.ts:1049.

}

return {
Expand All @@ -566,7 +563,8 @@ export function registerEVMResources(server: McpServer) {
tokenId: tokenId.toString(),
network,
...nftInfo,
owner
owner,
...(ownerError === undefined ? {} : { ownerError })
},
null,
2
Expand Down
35 changes: 26 additions & 9 deletions packages/mcp-server/src/core/services/balance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ const erc1155Abi = [
}
] as const;

async function readERC721Owner(tokenAddress: Address, tokenId: bigint, network: string): Promise<Address> {
return (await readContract(
{
address: tokenAddress,
abi: erc721Abi,
functionName: 'ownerOf',
args: [tokenId]
},
network
)) as Address;
}

/**
* Get the Sei balance for an address
* @param address Sei address
Expand Down Expand Up @@ -121,6 +133,19 @@ export async function getERC20Balance(
};
}

/**
* Get the current owner of a specific NFT
* @param tokenAddress NFT contract address
* @param tokenId Token ID to query
* @param network Network name or chain ID
* @returns Current owner address
*/
export async function getERC721Owner(tokenAddress: string, tokenId: bigint, network = DEFAULT_NETWORK): Promise<Address> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] getERC721Owner propagates lookup failures (good — that's the point of this PR), but isNFTOwner right below still catches everything and returns false. So the sibling surfaces built on it — the erc721_nft_ownership_check resource and the is_nft_owner tool — still report isOwner: false when the RPC call fails, which is exactly the "missing vs. failed" ambiguity this PR removes for the detail path. Since both now share readERC721Owner, it'd be cheap to let isNFTOwner propagate too (or return a discriminated result) and let the callers format the error the same way they do here. Not blocking, but the fix is currently half-applied.

const validatedTokenAddress = services.helpers.validateAddress(tokenAddress);

return readERC721Owner(validatedTokenAddress, tokenId, network);
}

/**
* Check if an address owns a specific NFT
* @param tokenAddress NFT contract address
Expand All @@ -134,15 +159,7 @@ export async function isNFTOwner(tokenAddress: string, ownerAddress: string, tok
const validatedOwnerAddress = services.helpers.validateAddress(ownerAddress);

try {
const actualOwner = (await readContract(
{
address: validatedTokenAddress,
abi: erc721Abi,
functionName: 'ownerOf',
args: [tokenId]
},
network
)) as Address;
const actualOwner = await readERC721Owner(validatedTokenAddress, tokenId, network);

return actualOwner.toLowerCase() === validatedOwnerAddress.toLowerCase();
} catch (error: unknown) {
Expand Down
28 changes: 8 additions & 20 deletions packages/mcp-server/src/core/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1038,28 +1038,15 @@ function registerWalletTools(server: McpServer) {
},
async ({ tokenAddress, tokenId, network = DEFAULT_NETWORK }) => {
try {
const nftInfo = await services.getERC721TokenMetadata(tokenAddress as Address, BigInt(tokenId), network);
const parsedTokenId = BigInt(tokenId);
const nftInfo = await services.getERC721TokenMetadata(tokenAddress as Address, parsedTokenId, network);

// Check ownership separately
let owner: `0x${string}` | null = null;
let ownerError: string | undefined;
try {
// This may fail if tokenId doesn't exist
owner = await services.getPublicClient(network).readContract({
address: tokenAddress as Address,
abi: [
{
inputs: [{ type: 'uint256' }],
name: 'ownerOf',
outputs: [{ type: 'address' }],
stateMutability: 'view',
type: 'function'
}
],
functionName: 'ownerOf',
args: [BigInt(tokenId)]
});
} catch (_e) {
// Ownership info not available
owner = await services.getERC721Owner(tokenAddress, parsedTokenId, network);
} catch (error) {
ownerError = error instanceof Error ? error.message : String(error);
}

return {
Expand All @@ -1072,7 +1059,8 @@ function registerWalletTools(server: McpServer) {
tokenId,
network,
...nftInfo,
owner: owner || 'Unknown'
owner: owner || 'Unknown',
...(ownerError === undefined ? {} : { ownerError })
},
null,
2
Expand Down
Loading
Loading