import { signEip191 } from '@reclaimprotocol/client'
import { defineTool, type RegisteredTool } from '../../mcp/server.ts'
import type { ReclaimOldClient } from '../client.ts'
/** Byte-for-byte the same format as the backend's `buildLinkMessage` in
* `application.controller.ts` — MUST stay in lockstep with it. */
function buildLinkMessage(
appId: string,
userId: string,
nonce: string,
): string {
return `Link ${appId} to account ${userId} | nonce: ${nonce}`
}
/** The uid the backend binds into the signed message — `req.user.uid` there.
* For a bearer identity this comes back from `POST /users/login`; for an eth
* identity, `OldIdentity.uid` (already `eth:
`) IS that uid. */
async function resolveUserId(client: ReclaimOldClient): Promise {
const identity = client.getIdentity()
if(!identity) {
throw new Error(
'Not authenticated against old devtools. Call reclaim_authenticate ' +
'first.',
)
}
if(identity.kind === 'eth') {
return identity.uid
}
const res = await client.loginWithToken() as { user?: { uid?: string } }
const uid = res?.user?.uid
if(!uid) {
throw new Error(
'Could not resolve your account uid from POST /users/login — ' +
'unexpected response shape.',
)
}
return uid
}
/**
* Link an unassociated app (minted via `issue_app_credentials`, or any
* eth-keypair app you already hold the private key for) to the currently
* authenticated dashboard account. Proves ownership by signing a one-time
* challenge with the app's private key — the private key itself is only
* ever used locally to sign, never sent to the server.
*/
export function linkAppTool(client: ReclaimOldClient): RegisteredTool {
return defineTool<{ appId: string, appSecret: string }>(
{
name: 'link_app_to_account',
description:
'Link an eth-keypair app to your authenticated dashboard account — ' +
'use this to keep using an appId/appSecret you already have ' +
'(instead of minting a fresh one via issue_app_credentials). ' +
'Requires reclaim_authenticate first. Fetches a one-time nonce, ' +
'signs `Link to account | nonce: ` locally ' +
'with `appSecret` (EIP-191, never transmitted), and submits the ' +
'signature. Fails with APP_ALREADY_LINKED if the app is already ' +
'linked to a (possibly different) account.',
inputSchema: {
type: 'object',
properties: {
appId: {
type: 'string',
description: 'The eth-address appId to link.',
},
appSecret: {
type: 'string',
description:
'The app\'s raw eth private key — used only locally to sign ' +
'the link challenge, never sent to the server.',
},
},
required: ['appId', 'appSecret'],
},
},
async({ appId, appSecret }) => {
const userId = await resolveUserId(client)
const { nonce } = await client.getLinkNonce()
const message = buildLinkMessage(appId, userId, nonce)
const signature = signEip191(message, appSecret)
await client.linkAppToAccount(appId, signature, nonce)
return {
linked: true,
appId,
note: 'App linked to your account. Run check_app_status to confirm.',
}
},
)
}