Xのタイムラインで、緑地に黒文字のシンプルな宣伝画像が流れてきた。
「120通貨以上のステーブルコインをワンストップで両替・決済」「AIエージェント向けにsera-mcpというMCPサーバーをMITライセンスで公開」
──オンチェーンFXという触れ込みのSera.cxだ。
見た瞬間に面白いと思ったのは機能の多さではなく、認証の設計だった。
testnet.sera.cxはアカウント登録やパスワード発行のフローを持たない。
秘密鍵そのものをアイデンティティとして扱い、EIP-712署名でAPIキーをプログラム的に発行する。
つまり「ユーザー登録」という概念自体が存在せず、ウォレットを持っている時点でもう認証済みという発想だ。
AIエージェントに渡すという文脈で考えると、これはかなり相性がいい。
エージェントに人間用のログインフォームを操作させる必要がなく、鍵ペアさえ渡せばAPIを叩ける。
この「鍵を持たせるだけで動く」感じに背中を押されて、実際にAIエージェントへ財布を持たせ、複数通貨の請求書をオンチェーンで自動精算させるエージェントを作った。
名前はsera-gate-agent。
過去に作ったwallet-agentという承認ゲート付き決済エージェントの土台をそのまま流用し、決済ロジックの中身をSera MCPに総入れ替えしている。
金額で自動実行と人間承認を分ける
今回のコンセプトは単純で、一定額以下は人間の確認なしで即実行、それを超えたら人間承認カードを発行してから実行する。
閾値判定のコードはこれだけだ。
# agent/tools/policy.py
Decision = Literal["AUTO", "REQUIRE_APPROVAL"]
DEFAULT_THRESHOLD_USD = "20"
def auto_threshold_usd() -> Decimal:
raw = os.environ.get("SERA_GATE_AUTO_THRESHOLD_USD", DEFAULT_THRESHOLD_USD)
try:
return Decimal(raw)
except InvalidOperation:
return Decimal(DEFAULT_THRESHOLD_USD)
def decide_auto_or_approval(amount_usd: Decimal | str) -> Decision:
"""amount_usd が閾値以下なら AUTO、超えるなら REQUIRE_APPROVAL を返す。"""
amount = amount_usd if isinstance(amount_usd, Decimal) else Decimal(str(amount_usd))
return "AUTO" if amount <= auto_threshold_usd() else "REQUIRE_APPROVAL"
これだけ見ると呆気ないくらい単純な関数だが、ここには外部依存が一切ない。
DynamoDBもBedrockも知らない、ただの純粋関数だ。
境界値のテストがそのまま単体テストになる設計にしたのは意図的で、決済の可否を左右するロジックほど、周りの複雑さから切り離しておきたかった。
ただし、この閾値はあくまでエージェント側のUXゲートに過ぎない。
コードにバグがあったり、署名鍵が漏れたりした場合でも、オンチェーンの実弾に上限をかけてくれるのはSera側のPOLICY_PRESETだ。
今回はstandard(1回$5000、1日$50000)を設定し、エージェント側の閾値($20)をそれよりずっと低く保つことで、デモ中は必ずエージェント側のUIが先に反応するようにしている。
二重の安全網というほど大げさなものではないが、「人間が確認する画面」と「オンチェーンで機械的に弾かれる上限」が別レイヤーで独立して効いているのは、財布を渡す相手がAIである以上ちゃんと意味があると思っている。
LLMに「実行系」のツールを触らせない
もうひとつ譲れなかったのが、閾値超えの決済を止める仕組みを「Claudeへの指示」だけに頼らないことだった。
システムプロンプトに「$20を超えたら必ず承認を求めてください」と書くだけでも大抵は守ってくれるが、それは所詮「大抵」でしかない。プロンプトインジェクションや、モデルの気まぐれな解釈ミスで破られる可能性をゼロにはできない。
そこで、sera-mcpの実行系ツール(execute_swap・convert_and_send・pay_invoiceなど)はAgentに直接晒さず、Python側のsettle_invoice_toolという1つの関数の内部からしか呼べないようにした。
# agent/agent.py
SERA_MCP_REJECTED_TOOLS = [
"sera.execute_swap",
"sera.convert_and_send",
"sera.pay_invoice",
"sera.place_order",
# ...(以下略、実行系ツールをすべて列挙)
]
Agentに公開されるのは相場を調べる読み取り系ツールと、このsettle_invoice_toolだけである。
閾値判定から承認待ちまでの一連の流れはこの関数の中に固定で書かれていて、Claudeがどう振る舞おうと迂回できない。
「LLMの指示追従」ではなく「コードの構造」で担保するという当たり前といえば当たり前の話だが、実装してみると地味に安心感が違う。
target_currencyに"XSGD"を渡して弾かれた話
実装が固まってからSepoliaテストネットで通しの動作確認をしていたときに、ちょっと面白い挙動を見た。
「$50のXSGD建て請求書を払って」とプロンプトを投げたら、エージェントが一度エラーを踏んだ。
Seraのpay_invoiceはtarget_currencyに法定通貨コード(例: "SGD")しか受け付けない仕様で、"XSGD"のようなトークンシンボルを渡すと弾かれる。
これはこちらの設計ミス(プロンプトの書き方が悪かった)なのだが、Claudeはエラーメッセージを見て「XSGDじゃなくてSGDで試すべきか」と自己判断し、target_currency="SGD"で再試行して普通に成功させた。
ツールのエラーメッセージをちゃんと読んで自己修正する、という当たり前の動きではあるものの、実際にログで見ると地味に感心する。
EIP-2612 Permitの壁
もうひとつ、実装中に一番時間を溶かしたのがこれだった。
sera-mcpのconvert_and_sendは、Intent部分のEIP-712署名はlocal signer modeで自動化してくれる。
ところが実行時にQuote requires an EIP-2612 permitというエラーが返ってきて、これは自動化されていなかった。
USDCなど一部トークンのdeposit許可(ERC-2612 Permit)は別途自分で署名する必要がある。
結局、convert_and_sendという1発で完結する便利なツールは諦めて、get_quote→(必要ならagent/tools/permit.pyで自前のPermit署名)→execute_swapという2段構えの手動フローに書き直した。
# agent/agent.py の _execute_swap から抜粋
quote = sera_mcp.call_tool(client, "sera.get_quote", {...})
exec_args = {"uuid": quote["uuid"], "route_params": quote["route_params"]}
permit_info = quote.get("permit")
if permit_info and permit_info.get("permit_required"):
eip712 = permit_info["eip712"]
exec_args["permit_signature"] = permit.sign_typed_data(signer_key, eip712)
exec_args["permit_deadline"] = eip712["message"]["deadline"]
return sera_mcp.call_tool(client, "sera.execute_swap", exec_args)
MCPサーバーが便利にラップしてくれている部分ほど、内部で何が自動化されていて何がされていないかを実機で確認しないと痛い目を見る、といういい教訓になった。
AgentCore RuntimeとVercelでハマった3つ
デプロイの段階でも小さくハマりどころがあった。
まずagentcore configureはデフォルトでdirect_code_deploy(素のPythonランタイム)を選ぶ。これだとsera-mcp(Node.js)を同梱できないので、--deployment-type containerを明示する必要がある。
次にVercel側。OIDC FederationのOIDCトークンはprocess.env.VERCEL_OIDC_TOKENでは取得できず、Next.jsのAPI Route実行時はリクエストヘッダー(x-vercel-oidc-token)経由でしか渡ってこない。
@vercel/oidc-aws-credentials-providerのawsCredentialsProvider()を使う必要があった。
さらにInvokeAgentRuntimeCommandのレスポンスストリームも罠だった。Vercel(Node.js runtime)上ではresp.responseはWeb標準のReadableStreamではなく、AWS SDKのsdkStreamMixinでラップされたオブジェクトになっている。getReader()を呼んで小一時間ハマったが、正解はtransformToString()を使うことだった。
どれも単体では大した話ではないが、積み重なると「なぜ動かないのか」の切り分けに時間を食う。特にストリームの型違いは、エラーメッセージが素直に「型が違います」とは教えてくれないタイプのやつだった。
実際に動いたところ
最終的に、Sepoliaテストネット上で3件のオンチェーン決済を実機確認できた。
$15の請求書はAUTOで自動実行、$50の請求書は承認カードが発行され、別プロセスでapproveしてから実行される。
DynamoDBに切り替えてもroute_summaryが正しくネストしたMapとして書き込まれることを確認し、最後はVercel本番環境からのチャット送信→AgentCore Runtime呼び出し→承認カード発行→ブラウザでの承認→オンチェーン決済実行まで、End-to-Endで通った。
構成はこんな感じになっている。
動作フローはこう。
短いデモ動画も撮った(英語バージョン)
終わりに
正直なところ、AIエージェントに財布を持たせるという行為自体は、まだ多くの人にとって落ち着かない話だと思う。
今回作ったものも、閾値以下は問答無用で自動実行してしまう時点で、人によっては「$20でも人間に聞いてほしい」と感じる方もいると思う。
閾値をどこに引くかという話は技術の話であると同時に、どれだけAIを信用するかという話でもあって、その線引きに絶対の正解はない。
ただ、少なくとも「どこまではAIに任せて、どこからは人間が判断するか」を、プロンプトの言葉遣いではなくコードの構造として固定できる、という点は今回やってみてよかった発見だった。
次はこの閾値そのものを、支払い履歴やリスクスコアに応じて動的に動かす、みたいなこともやってみたい。
最後まで読んでいただきありがとうございました。


