Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ npm run dev
| `MCP_TRANSPORT` | `stdio` (default) or `sse` for Streamable HTTP. |
| `MCP_HOST` | Bind address for HTTP mode (default `127.0.0.1`). |
| `MCP_PORT` | Port for HTTP mode (default `3000`). |
| `EVM_CHAIN_RPCS` | Optional JSON object mapping **chain id strings** to **arrays of RPC URLs** (primary first, fallbacks next). Used to build an ethers v6 `FallbackProvider` per chain for EVM tools. Example (shell-safe quoting) with public RPCs for **mainnet (1)**, **sepolia (11155111)**, **base (8453)**, **optimism (10)**: `EVM_CHAIN_RPCS='{"1":["https://cloudflare-eth.com","https://rpc.ankr.com/eth"],"11155111":["https://rpc.sepolia.org","https://rpc.ankr.com/eth_sepolia"],"8453":["https://mainnet.base.org","https://base.publicnode.com"],"10":["https://mainnet.optimism.io","https://optimism.publicnode.com"]}'`. Empty or unset means no EVM providers are registered. Invalid JSON causes startup to fail. |
| `EVM_CHAIN_RPCS` | Optional JSON object mapping **chain id strings** to **arrays of RPC URLs** (primary first, fallbacks next). Used to build an ethers v6 `FallbackProvider` per chain for EVM tools. Example (shell-safe quoting) with public RPCs for **base (8453)**, **mainnet (1)**, **optimism (10)**: `EVM_CHAIN_RPCS='{"8453":["https://mainnet.base.org","https://base.publicnode.com"],"1":["https://cloudflare-eth.com","https://rpc.ankr.com/eth"],"10":["https://mainnet.optimism.io","https://optimism.publicnode.com"]}'`. Empty or unset means no EVM providers are registered. Invalid JSON causes startup to fail. |

---

Expand Down
20 changes: 20 additions & 0 deletions src/clients/nodeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type StorageObject
} from '@oceanprotocol/lib'
import type { Multiaddr } from '@multiformats/multiaddr'
import type { Signer } from 'ethers'

type NodeP2P = {
nodeId: string | null
Expand Down Expand Up @@ -665,6 +666,25 @@ export class NodeClient {
}
}

/** Mint a JWT by signing with a Signer (ephemeral or user key); ocean.js fetches the nonce and signs internally. */
async createAuthTokenWithSigner(
node: NodeP2P,
signer: Signer,
timeout: number
): Promise<string> {
try {
const token = await getP2p().generateAuthToken(
signer,
node,
AbortSignal.timeout(timeout)
)
return typeof token === 'string' ? token : `${token}`
} catch (error) {
const message = error instanceof Error ? error.message : `${error}`
throw new Error(`P2P createAuthToken (signer) failed: ${message}`)
}
}

async policyServerPassthrough<T = unknown>(
node: NodeP2P,
request: Record<string, unknown>,
Expand Down
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,13 @@ async function createServerContext(): Promise<ServerContext> {

async function main(): Promise<void> {
if (!process.env.PRIVATE_KEY) {
// No key provided: generate a disposable libp2p identity for this session.
// This is ONLY the MCP server's network identity used to reach ocean-node
// peers — NOT the key that authorizes or pays for compute jobs. That
// consumer key is chosen per-job via the create_auth_token tool.
process.env.PRIVATE_KEY = Wallet.createRandom().privateKey
console.warn(
'PRIVATE_KEY was not provided; generated an ephemeral random key for this session.'
'No PRIVATE_KEY set: generated a disposable libp2p network identity for this MCP server session (lost on restart). This is the server peer identity, not a key you fund or that authorizes your compute jobs — choose that per-job via create_auth_token. Set PRIVATE_KEY for a stable server identity.'
)
}

Expand Down
8 changes: 4 additions & 4 deletions src/prompts/registerPrompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,10 +285,10 @@ const DIAGNOSTICS: Record<
payment_errors: {
title: 'Payment or Escrow Errors',
checks: [
'USDC balance on Base network is sufficient',
"Consumer's fee-token balance on Base is sufficient (USDC or COMPY)",
'ESCROW_CLAIM_TIMEOUT (default 3600s) - increase if jobs are long-running',
'feeToken in compute environment matches the payment token the user is sending',
'Base USDC address: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
'Base fee tokens: USDC 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 · COMPY (Ocean grant token) 0x298f163244e0c8cc9316D6E97162e5792ac5d410'
],
commands: [
'npm run cli getUserFundsEscrow --token 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
Expand Down Expand Up @@ -501,7 +501,7 @@ export function registerPrompts(server: McpServer): void {
# Ocean Node - Environment Configuration

PRIVATE_KEY=${key}
RPCS={"11155111": "https://sepolia.infura.io/v3/YOUR_INFURA_KEY", "8453": "https://mainnet.base.org"}
RPCS={"8453": "https://mainnet.base.org"}
HTTP_API_PORT=8000

ALLOWED_ADMINS=["0xYOUR_ADMIN_WALLET"]
Expand Down Expand Up @@ -629,7 +629,7 @@ ESCROW_CLAIM_TIMEOUT=3600
### Step 1 - Set Environment Variables
\`\`\`bash
export PRIVATE_KEY=0xYOUR_PRIVATE_KEY
export RPC=https://sepolia.infura.io/v3/YOUR_KEY
export RPC=https://mainnet.base.org
export NODE_URL=http://YOUR_NODE_URL:8000
\`\`\`

Expand Down
18 changes: 18 additions & 0 deletions src/resources/resourceCatalog.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { DocIndex } from '../docs/loader.js'
import type { EvmProviderRegistry } from '../evm/evmProviderRegistry.js'
import { C2D_ALGORITHM_AUTHORING_MARKDOWN } from '../utils/c2dAlgorithmAuthoring.js'
import { C2D_FIND_PROVIDER_RESOURCE_MARKDOWN } from '../utils/c2dProviderSearchString.js'

export const C2D_FIND_PROVIDER_URI = 'ocean://docs/c2d-find-provider-search'
export const C2D_ALGORITHM_AUTHORING_URI = 'ocean://docs/c2d-algorithm-authoring'
export const EVM_SUPPORTED_CHAINS_URI = 'ocean://evm/supported-chains'

export type ResourceSummary = {
Expand Down Expand Up @@ -66,6 +68,14 @@ export function listBuiltinResources(): ResourceSummary[] {
'How ocean-node advertises compute capacity for DHT discovery and how to use buildFindProviderC2dContent + find_provider.',
mimeType: 'text/markdown'
},
{
name: 'c2d-algorithm-authoring',
uri: C2D_ALGORITHM_AUTHORING_URI,
title: 'C2D algorithm authoring (rawcode + prebuilt image)',
description:
'Recommended C2D path: prebuilt oceanprotocol/c2d_examples image + inline rawcode. Image catalog, algorithm object shape, /data input/output contract, constraints, free-compute auth, and node targeting.',
mimeType: 'text/markdown'
},
{
name: 'evm-supported-chains',
uri: EVM_SUPPORTED_CHAINS_URI,
Expand All @@ -89,6 +99,14 @@ export async function getBuiltinResourceContent(
}
}

if (uri === C2D_ALGORITHM_AUTHORING_URI) {
return {
uri,
mimeType: 'text/markdown',
text: C2D_ALGORITHM_AUTHORING_MARKDOWN
}
}

if (uri !== EVM_SUPPORTED_CHAINS_URI) {
return undefined
}
Expand Down
97 changes: 97 additions & 0 deletions src/test/unit/tools/createAuthToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { expect } from 'chai'

import { registerP2pProviderTools } from '../../../tools/p2pProviderTools.js'

type Handler = (args: any) => Promise<any>

// Hardhat account #0 private key — valid test key, no real funds.
const VALID_PK = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'

function getHandler(nodeClient: any): Handler {
let handler: Handler | undefined
const server = {
registerTool(name: string, _config: unknown, fn: Handler) {
if (name === 'create_auth_token') handler = fn
}
}
registerP2pProviderTools({ server: server as any, nodeClient })
if (!handler) throw new Error('create_auth_token was not registered')
return handler
}

function resultObject(res: any) {
return JSON.parse(res.content[0].text).result
}

describe('create_auth_token tool', () => {
it('ephemeral: returns token + generated address + throwaway privateKey', async () => {
let signerAddress: string | undefined
const nodeClient = {
createAuthTokenWithSigner(_node: any, signer: any) {
signerAddress = signer.address
return Promise.resolve('jwt-ephemeral')
}
}
const res = await getHandler(nodeClient)({ nodeId: 'peer-1', ephemeral: true })
expect(res.isError).to.not.equal(true)
const out = resultObject(res)
expect(out.token).to.equal('jwt-ephemeral')
expect(out.consumerAddress).to.match(/^0x[0-9a-fA-F]{40}$/)
expect(out.privateKey).to.match(/^0x[0-9a-fA-F]{64}$/)
expect(out.consumerAddress.toLowerCase()).to.equal(signerAddress?.toLowerCase())
expect(out.disclaimer).to.be.a('string').and.have.length.greaterThan(0)
})

it('privateKey: returns token + derived address and never echoes the key', async () => {
const nodeClient = {
createAuthTokenWithSigner() {
return Promise.resolve('jwt-own')
}
}
const res = await getHandler(nodeClient)({ nodeId: 'peer-1', privateKey: VALID_PK })
expect(res.content[0].text).to.not.contain(VALID_PK)
const out = resultObject(res)
expect(out.token).to.equal('jwt-own')
expect(out.consumerAddress).to.match(/^0x[0-9a-fA-F]{40}$/)
expect(out.privateKey).to.equal(undefined)
})

it('rejects when no auth source is provided', async () => {
const res = await getHandler({})({ nodeId: 'peer-1' })
expect(res.isError).to.equal(true)
})

it('rejects when more than one auth source is provided', async () => {
const res = await getHandler({})({
nodeId: 'peer-1',
ephemeral: true,
privateKey: VALID_PK
})
expect(res.isError).to.equal(true)
})

it('returns isError on an invalid privateKey and never echoes it', async () => {
const nodeClient = {
createAuthTokenWithSigner() {
return Promise.resolve('x')
}
}
const badKey = 'super-secret-not-hex-passphrase'
const res = await getHandler(nodeClient)({ nodeId: 'peer-1', privateKey: badKey })
expect(res.isError).to.equal(true)
expect(res.content[0].text).to.not.contain(badKey)
})

it('privateKey: a downstream mint failure never echoes the key', async () => {
const nodeClient = {
createAuthTokenWithSigner() {
// Worst case: the underlying error text embeds the key. The handler must
// not surface it on the user-key path.
return Promise.reject(new Error(`signing exploded ${VALID_PK}`))
}
}
const res = await getHandler(nodeClient)({ nodeId: 'peer-1', privateKey: VALID_PK })
expect(res.isError).to.equal(true)
expect(res.content[0].text).to.not.contain(VALID_PK)
})
})
53 changes: 53 additions & 0 deletions src/tools/evmContractTools.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Contract, formatUnits, getAddress } from 'ethers'
import { z } from 'zod/v4'

import { stringifyError, textContent } from '../utils/format.js'
Expand All @@ -9,6 +10,12 @@ import {
type EvmToolParams
} from './evmToolUtils.js'

const ERC20_INFO_ABI = [
'function decimals() view returns (uint8)',
'function symbol() view returns (string)',
'function name() view returns (string)'
]

export function registerEvmContractTools({ server, evmRegistry }: EvmToolParams): void {
server.registerTool(
'get_balance',
Expand Down Expand Up @@ -143,6 +150,52 @@ export function registerEvmContractTools({ server, evmRegistry }: EvmToolParams)
}
)

server.registerTool(
'get_erc20_token_info',
{
title: 'Get ERC-20 token info (decimals, symbol, name)',
description:
'Reads `decimals`, `symbol`, and `name` from an ERC-20 token contract (read-only). Use this to denominate raw amounts (e.g. `payment.amount` from initializeCompute, `escrow_get_user_funds`) into human-readable units before showing them to the user. Optionally pass `rawAmount` to get back the human-readable string as well.',
inputSchema: {
chainId: z
.number()
.int()
.positive()
.describe('EVM chain id configured in EVM_CHAIN_RPCS.'),
tokenAddress: z.string().describe('ERC-20 token contract address (0x...).'),
rawAmount: z
.string()
.optional()
.describe(
'Optional raw amount (decimal string) to format using the token decimals; returns `formattedAmount`.'
)
}
},
async ({ chainId, tokenAddress, rawAmount }) => {
try {
const provider = getProviderOrThrow(evmRegistry, chainId)
const contract = new Contract(getAddress(tokenAddress), ERC20_INFO_ABI, provider)
const [decimalsRaw, symbol, name] = await Promise.all([
contract.decimals(),
contract.symbol(),
contract.name()
])
const decimals = Number(decimalsRaw)
return commandResultPayload('get_erc20_token_info', {
address: getAddress(tokenAddress),
decimals,
symbol,
name,
...(rawAmount !== undefined
? { rawAmount, formattedAmount: formatUnits(BigInt(rawAmount), decimals) }
: {})
})
} catch (error) {
return { ...textContent(stringifyError(error)), isError: true }
}
}
)

registerEscrowTools({ server, evmRegistry })
registerAccessListTools({ server, evmRegistry })
}
Loading
Loading