From 207f9c2164de6fc0b9fb9cbacc27882fecc5e74c Mon Sep 17 00:00:00 2001 From: Shotaro Nakamura Date: Sun, 21 Jun 2026 14:09:05 +0900 Subject: [PATCH] feat: introduce better mcp --- apps/csbie-server/src/routes/mcp.ts | 308 +++++--- apps/csbie-server/src/rpc/methods.ts | 2 + apps/csbie-ui/src/App.vue | 12 + .../src/components/ApiKeyPolicyEditor.vue | 2 + apps/csbie-ui/src/constants/nav.ts | 4 +- apps/csbie-ui/src/constants/trade.ts | 13 +- .../src/features/history/HistoryView.vue | 405 ++++++++++- .../src/features/portfolio/PortfolioView.vue | 175 ++++- .../src/features/trading/trading-data.ts | 151 +++- .../src/features/trading/useTradingSession.ts | 165 ++++- apps/csbie-ui/src/styles/ui.ts | 23 +- apps/csbie-ui/src/types/trading.ts | 61 +- packages/sbi-client/src/index.ts | 2 + packages/sbi-client/src/markets.ts | 3 + packages/sbi-client/src/methods/index.ts | 48 +- packages/sbi-client/src/methods/types.ts | 26 + packages/sbi-client/src/methods/us-stock.ts | 670 +++++++++++++++++- packages/sbi-client/src/types.ts | 28 +- scripts/check-env.ts | 18 +- 19 files changed, 1862 insertions(+), 254 deletions(-) diff --git a/apps/csbie-server/src/routes/mcp.ts b/apps/csbie-server/src/routes/mcp.ts index 34d4c3d..b3e898b 100644 --- a/apps/csbie-server/src/routes/mcp.ts +++ b/apps/csbie-server/src/routes/mcp.ts @@ -7,13 +7,7 @@ import { eq } from 'drizzle-orm' import * as z from 'zod/v4' import type { AppBindings, AuthContext } from '../context' import { sbiPasskeys } from '../db/schema' -import { - invokeSbiMethod, - isCashOrderMethod, - isTradingMethod, - RPC_METHODS, - type RpcMethod, -} from '../rpc/methods' +import { invokeSbiMethod, isCashOrderMethod, isTradingMethod, type RpcMethod } from '../rpc/methods' import { connectSbi } from '../rpc/sbi-session' import type { StoredSbiPasskeySecret } from './admin' import { @@ -33,8 +27,6 @@ const requireAuthenticated = (auth: AuthContext) => { if (!auth.authenticated) throw new Error('unauthorized') } -const toolNameForMethod = (method: RpcMethod) => `csbie_sbi_${method.replaceAll('.', '_')}` - const ORDER_SUBMIT_TICKET_TTL_MS = 10 * 60 * 1000 type OrderSubmitTicket = { @@ -81,10 +73,6 @@ const orderSubmitMethodByEstimateMethod = { const submitMethodForEstimateMethod = (method: RpcMethod) => orderSubmitMethodByEstimateMethod[method as keyof typeof orderSubmitMethodByEstimateMethod] -const isDirectOrderSubmitMethod = (method: RpcMethod) => isTradingMethod(method) - -const mcpExposedRpcMethods = RPC_METHODS.filter((method) => !isDirectOrderSubmitMethod(method)) - const orderSubmitParams = (value: unknown, confirmationId?: string) => { if (!value || typeof value !== 'object' || Array.isArray(value)) return { allowTrading: true } return { @@ -179,6 +167,17 @@ const orderInquiryOptionsSchema = z.object({ status: z.enum(['open', 'executed', 'cancelled', 'expired', 'rejected', 'unknown']).optional(), }) +const orderDetailSchema = z.object({ + orderNumber: z.string().min(1).optional().describe('Order number shown in order inquiry'), + orderId: orderIdSchema.optional().describe('Order id shown in order inquiry'), + issueCode: issueCodeSchema.optional(), + market: marketCodeSchema.describe('US stock market code for order detail'), +}) + +const tradeRecordInquiryOptionsSchema = orderInquiryOptionsSchema.extend({ + accountType: accountTypeSchema.optional(), +}) + const boardOptionsSchema = issueOptionsSchema.extend({ accountType: accountTypeSchema.optional(), side: z @@ -337,6 +336,8 @@ const placeOrderCorrectionSchema = orderCorrectionSchema.extend({ const orderCancelSchema = z.object({ orderNumber: z.string().min(1).describe('Order number shown in order inquiry'), orderId: orderIdSchema.optional().describe('Original order id shown in order inquiry'), + issueCode: issueCodeSchema.optional().describe('Original issue code shown in order inquiry'), + market: marketCodeSchema.optional().describe('Original market code shown in order inquiry'), tradeId: z.string().min(1).optional().describe('Original trade id code'), cancelType: z.string().min(1).optional().describe('Additional cancel flag'), }) @@ -628,8 +629,10 @@ const methodParamSchemas = { 'market.ranking.sbi': undefined, 'news.list': undefined, 'watchlist.list': undefined, + 'orders.inquiry.detail': orderDetailSchema, 'orders.inquiry.executionsToday': orderInquiryOptionsSchema.optional(), 'orders.inquiry.open': orderInquiryOptionsSchema.optional(), + 'orders.inquiry.tradeRecords': tradeRecordInquiryOptionsSchema, 'orders.cash.preOrder': cashOrderPreOrderSchema, 'orders.cash.estimate': cashOrderSchema, 'orders.cash.place': placeCashOrderSchema, @@ -665,6 +668,84 @@ const methodParamSchemas = { 'orders.exchange.place': placeExchangeOrderSchema, } satisfies Record +const getActionToMethod = { + sessionProfile: 'session.profile', + accountProfile: 'account.profile', + currentAssets: 'account.assets.current', + buyingPower: 'account.power.buyingPower', + collateralRatio: 'account.power.collateralRatio', + cashPositions: 'account.positions.cash', + cashPositionDetail: 'account.positions.cashDetail', + cashPositionForIssue: 'account.positions.cashForIssue', + marginPositions: 'account.positions.margin', + marginPositionDetail: 'account.positions.marginDetail', + marginPositionForIssue: 'account.positions.marginForIssue', + marginSummaryForIssue: 'account.positions.marginSummaryForIssue', + marginDetailsForIssue: 'account.positions.marginDetailsForIssue', + closeableMarginPositions: 'account.positions.closeableMargin', + deliverableMarginPositions: 'account.positions.deliverableMargin', + unrealizedProfitLoss: 'account.profitLoss.unrealized', + issueSearch: 'market.issue.search', + issueSuggest: 'market.issue.suggest', + allowedPrices: 'market.issue.allowedPrices', + board: 'market.issue.board', + chart: 'market.issue.chart', + openOrdersForIssue: 'market.issue.openOrders', + tradingInfo: 'market.issue.tradingInfo', + majorIndexes: 'market.index.major', + marketOverview: 'market.overview', + marketRanking: 'market.ranking.market', + sectorRanking: 'market.ranking.sector', + sbiRanking: 'market.ranking.sbi', + news: 'news.list', + watchlist: 'watchlist.list', + orderDetail: 'orders.inquiry.detail', + executionsToday: 'orders.inquiry.executionsToday', + openOrders: 'orders.inquiry.open', + tradeRecords: 'orders.inquiry.tradeRecords', + cashOrderPreflight: 'orders.cash.preOrder', + marginOpenPreflight: 'orders.margin.preOrderOpen', + marginClosePreflight: 'orders.margin.preOrderClose', + actualDeliveryPreflight: 'orders.margin.preOrderActualDelivery', + themeInvestmentList: 'orders.themeInvestment.list', + exchangeRate: 'orders.exchange.rate', +} as const satisfies Record + +const changeActionToEstimateMethod = { + cashOrder: 'orders.cash.estimate', + cashOrderCorrection: 'orders.cash.estimateCorrection', + cashOrderCorrectionConfirm: 'orders.cash.estimateCorrectionConfirm', + cashOrderCancel: 'orders.cash.estimateCancel', + marginOpenOrder: 'orders.margin.estimateOpen', + marginCloseOrder: 'orders.margin.estimateClose', + marginCloseSummaryOrder: 'orders.margin.estimateSummary', + marginCloseSummaryConfirm: 'orders.margin.estimateCloseSummary', + actualDeliveryOrder: 'orders.margin.estimateActualDelivery', + ifdOrder: 'orders.ifd.estimate', + ifdOrderCorrection: 'orders.ifd.estimateCorrection', + ifdOrderCancel: 'orders.ifd.estimateCancel', + themeInvestmentOrder: 'orders.themeInvestment.estimate', + exchangeOrder: 'orders.exchange.estimate', +} as const satisfies Record + +const getActions = Object.keys(getActionToMethod) as Array +const changeActions = Object.keys(changeActionToEstimateMethod) as Array< + keyof typeof changeActionToEstimateMethod +> +const getActionSchema = z.enum(['capabilities', 'passkeys', ...getActions]) +const changeActionSchema = z.enum(changeActions) + +const parseMethodParams = (method: RpcMethod, input: unknown) => { + const schema = methodParamSchemas[method] + if (!schema) { + if (input !== undefined && input !== null) { + throw new Error(`${method} does not accept input`) + } + return undefined + } + return schema.parse(input) +} + const createMcpServer = (c: Context) => { const db = c.get('db') const config = c.get('config') @@ -675,59 +756,33 @@ const createMcpServer = (c: Context) => { version: '0.1.0', }) - server.registerTool( - 'csbie_sbi_methods', - { - title: 'List SBI RPC Methods', - description: 'List SBI client methods exposed through CSBIE.', - inputSchema: {}, - }, - async () => { - requireAuthenticated(auth) - return textResult({ - methods: mcpExposedRpcMethods, - submitTool: 'csbie_sbi_submit_order', + const listPasskeys = async () => { + const rows = await db + .select({ + id: sbiPasskeys.id, + label: sbiPasskeys.label, + keyringAccount: sbiPasskeys.keyringAccount, + createdAt: sbiPasskeys.createdAt, + updatedAt: sbiPasskeys.updatedAt, }) - }, - ) + .from(sbiPasskeys) + .orderBy(sbiPasskeys.createdAt) + return Promise.all( + rows.map(async ({ keyringAccount, ...row }) => { + const secret = await readSecret(keyringAccount) + const hasDeviceId = Boolean(effectiveSbiDeviceId(secret)) + const hasTradePassword = Boolean(effectiveSbiTradePassword(secret)) + return { + ...row, + hasTradePassword, + hasDeviceId, + cashOrderReady: hasTradePassword && hasDeviceId, + } + }), + ) + } - server.registerTool( - 'csbie_sbi_passkeys', - { - title: 'List SBI Passkeys', - description: 'List saved SBI passkey profiles. Secret material is never returned.', - inputSchema: {}, - }, - async () => { - requireAuthenticated(auth) - const rows = await db - .select({ - id: sbiPasskeys.id, - label: sbiPasskeys.label, - keyringAccount: sbiPasskeys.keyringAccount, - createdAt: sbiPasskeys.createdAt, - updatedAt: sbiPasskeys.updatedAt, - }) - .from(sbiPasskeys) - .orderBy(sbiPasskeys.createdAt) - const passkeys = await Promise.all( - rows.map(async ({ keyringAccount, ...row }) => { - const secret = await readSecret(keyringAccount) - const hasDeviceId = Boolean(effectiveSbiDeviceId(secret)) - const hasTradePassword = Boolean(effectiveSbiTradePassword(secret)) - return { - ...row, - hasTradePassword, - hasDeviceId, - cashOrderReady: hasTradePassword && hasDeviceId, - } - }), - ) - return textResult({ passkeys }) - }, - ) - - const callSbiMethod = async (method: RpcMethod, passkeyId: string, params: unknown) => { + const invokeCheckedSbiMethod = async (method: RpcMethod, passkeyId: string, params: unknown) => { requireAuthenticated(auth) if (auth.type === 'apiKey') { @@ -767,41 +822,105 @@ const createMcpServer = (c: Context) => { } const client = await connectSbi(db, config, passkeyId) - const result = await invokeSbiMethod(client, method, params) - const submitMethod = submitMethodForEstimateMethod(method) - if (!submitMethod) return textResult(result) + return invokeSbiMethod(client, method, params) + } + + const createChangeRequest = async ( + action: keyof typeof changeActionToEstimateMethod, + passkeyId: string, + input: unknown, + ) => { + const estimateMethod = changeActionToEstimateMethod[action] + const submitMethod = submitMethodForEstimateMethod(estimateMethod) + if (!submitMethod) throw new Error(`${action} cannot create a confirmable request`) + + const params = parseMethodParams(estimateMethod, input) + const preview = await invokeCheckedSbiMethod(estimateMethod, passkeyId, params) cleanupExpiredOrderSubmitTickets() const uuid = randomUUID() const expiresAt = new Date(Date.now() + ORDER_SUBMIT_TICKET_TTL_MS) - const confirmationId = confirmationIdFromPreview(result) + const confirmationId = confirmationIdFromPreview(preview) orderSubmitTickets.set(uuid, { passkeyId, - estimateMethod: method, + estimateMethod, submitMethod, params, confirmationId, authKey: authKey(auth), expiresAt, }) - return textResult({ - preview: result, - submit: { - uuid, - tool: 'csbie_sbi_submit_order', - expiresAt: expiresAt.toISOString(), - }, - }) + + return { + uuid, + expiresAt: expiresAt.toISOString(), + preview, + confirmTool: 'confirm-request', + } } server.registerTool( - 'csbie_sbi_submit_order', + 'csbie-get', { - title: 'Submit Estimated SBI Order', + title: 'Get SBI Data', description: - 'Submit the same SBI order as a previous MCP estimate result by UUID. The UUID expires shortly and is bound to the same authenticated caller.', + 'Read SBI data through a small abstract action API. This tool never places, corrects, cancels, or otherwise changes real orders.', inputSchema: { - uuid: z.string().uuid().describe('UUID returned by an order estimate tool'), + action: getActionSchema.describe('Read action to perform'), + passkeyId: z + .string() + .optional() + .describe('Saved SBI passkey id. Required for SBI-backed read actions.'), + input: z.unknown().optional().describe('Action input object'), + }, + }, + async ({ action, passkeyId, input }) => { + requireAuthenticated(auth) + + if (action === 'capabilities') { + return textResult({ + getActions, + changeActions, + changeTool: 'csbie-request-change', + confirmTool: 'confirm-request', + }) + } + + if (action === 'passkeys') return textResult({ passkeys: await listPasskeys() }) + + const method = getActionToMethod[action] + if (!method) throw new Error(`unsupported get action: ${String(action)}`) + if (!passkeyId) throw new Error(`${action} requires passkeyId`) + + const params = parseMethodParams(method, input) + return textResult(await invokeCheckedSbiMethod(method, passkeyId, params)) + }, + ) + + server.registerTool( + 'csbie-request-change', + { + title: 'Create SBI Change Request', + description: + 'Prepare a real SBI change by running the corresponding estimate/preview and returning a UUID. This tool never submits the change; pass the UUID to confirm-request.', + inputSchema: { + action: changeActionSchema.describe('Change action to prepare'), + passkeyId: z.string().describe('Saved SBI passkey id'), + input: z.unknown().describe('Change action input object'), + }, + }, + async ({ action, passkeyId, input }) => + textResult(await createChangeRequest(action, passkeyId, input)), + ) + + server.registerTool( + 'confirm-request', + { + title: 'Confirm SBI Change Request', + description: + 'Submit a previously prepared SBI change request by UUID. The UUID expires shortly, is single-use, and is bound to the same authenticated caller.', + inputSchema: { + uuid: z.string().uuid().describe('UUID returned by csbie-request-change'), }, }, async ({ uuid }) => { @@ -809,40 +928,21 @@ const createMcpServer = (c: Context) => { cleanupExpiredOrderSubmitTickets() const ticket = orderSubmitTickets.get(uuid) - if (!ticket) throw new Error('order submit uuid not found or expired') + if (!ticket) throw new Error('request uuid not found or expired') if (ticket.authKey !== authKey(auth)) { - throw new Error('order submit uuid was created by a different authenticated caller') + throw new Error('request uuid was created by a different authenticated caller') } orderSubmitTickets.delete(uuid) - return callSbiMethod( + const result = await invokeCheckedSbiMethod( ticket.submitMethod, ticket.passkeyId, orderSubmitParams(ticket.params, ticket.confirmationId), ) + return textResult(result) }, ) - for (const method of mcpExposedRpcMethods) { - const paramsSchema = methodParamSchemas[method] - server.registerTool( - toolNameForMethod(method), - { - title: `Call ${method}`, - description: `Connect with one saved SBI passkey and call ${method}. API key method permissions and trading limits are enforced.`, - inputSchema: { - passkeyId: z.string().describe('Saved SBI passkey id from csbie_sbi_passkeys'), - ...(paramsSchema - ? { - params: paramsSchema.describe(`${method} params`), - } - : {}), - }, - }, - async ({ passkeyId, params }) => callSbiMethod(method, passkeyId, params), - ) - } - return server } diff --git a/apps/csbie-server/src/rpc/methods.ts b/apps/csbie-server/src/rpc/methods.ts index 6b1c539..1333822 100644 --- a/apps/csbie-server/src/rpc/methods.ts +++ b/apps/csbie-server/src/rpc/methods.ts @@ -31,8 +31,10 @@ export const RPC_METHODS = [ 'market.ranking.sbi', 'news.list', 'watchlist.list', + 'orders.inquiry.detail', 'orders.inquiry.executionsToday', 'orders.inquiry.open', + 'orders.inquiry.tradeRecords', 'orders.cash.preOrder', 'orders.cash.estimate', 'orders.cash.place', diff --git a/apps/csbie-ui/src/App.vue b/apps/csbie-ui/src/App.vue index cf94c1b..28f3140 100644 --- a/apps/csbie-ui/src/App.vue +++ b/apps/csbie-ui/src/App.vue @@ -105,6 +105,7 @@ const { holdingsMarketValue, totalProfitLoss, totalProfitLossRate, + marketIndexes, orders, cancelingOrderKey, orderHistoryLoaded, @@ -146,6 +147,11 @@ const { askPlaceOrder, placeCashOrder, cancelOrder, + loadOrderDetail, + loadTradeRecords, + loadPositionDetail, + estimateOrderCorrection, + placeOrderCorrection, downloadCsv, openTradeForStock, openTradeForPosition, @@ -232,6 +238,7 @@ onMounted(async () => { :holdings-market-value="holdingsMarketValue" :total-profit-loss="totalProfitLoss" :total-profit-loss-rate="totalProfitLossRate" + :market-indexes="marketIndexes" :stock-asset-ratio="stockAssetRatio" :cash-asset-ratio="cashAssetRatio" :positions="positions" @@ -241,6 +248,7 @@ onMounted(async () => { :connected="connected" :order-history-loaded="orderHistoryLoaded" :order-history-notice="orderHistoryNotice" + :load-position-detail="loadPositionDetail" @connect="connect" @open-position=" (code) => @@ -304,6 +312,10 @@ onMounted(async () => { :canceling-order-key="cancelingOrderKey" :order-history-loaded="orderHistoryLoaded" :order-history-notice="orderHistoryNotice" + :load-order-detail="loadOrderDetail" + :load-trade-records="loadTradeRecords" + :estimate-order-correction="estimateOrderCorrection" + :place-order-correction="placeOrderCorrection" @refresh="loadTradingData" @cancel="cancelOrder" /> diff --git a/apps/csbie-ui/src/components/ApiKeyPolicyEditor.vue b/apps/csbie-ui/src/components/ApiKeyPolicyEditor.vue index de643f4..2df4a1b 100644 --- a/apps/csbie-ui/src/components/ApiKeyPolicyEditor.vue +++ b/apps/csbie-ui/src/components/ApiKeyPolicyEditor.vue @@ -39,8 +39,10 @@ const rpcMethods = [ 'market.ranking.sbi', 'news.list', 'watchlist.list', + 'orders.inquiry.detail', 'orders.inquiry.executionsToday', 'orders.inquiry.open', + 'orders.inquiry.tradeRecords', 'orders.cash.estimate', 'orders.cash.place', 'orders.cash.estimateCorrection', diff --git a/apps/csbie-ui/src/constants/nav.ts b/apps/csbie-ui/src/constants/nav.ts index f92982c..29ac2e1 100644 --- a/apps/csbie-ui/src/constants/nav.ts +++ b/apps/csbie-ui/src/constants/nav.ts @@ -8,14 +8,14 @@ export type PageMeta = { } export const pageMeta: Record = { - portfolio: { title: 'ポートフォリオ', icon: WalletCards }, + portfolio: { title: 'ホーム', icon: WalletCards }, trade: { title: '取引', icon: ArrowLeftRight }, history: { title: '取引履歴', icon: History }, settings: { title: '設定', icon: Settings }, } export const sidebarItems: Array<{ name: RouteName; label: string; icon: Component }> = [ - { name: 'portfolio', label: '資産一覧', icon: pageMeta.portfolio.icon }, + { name: 'portfolio', label: 'ホーム', icon: pageMeta.portfolio.icon }, { name: 'trade', label: '取引', icon: pageMeta.trade.icon }, { name: 'settings', label: '設定', icon: pageMeta.settings.icon }, ] diff --git a/apps/csbie-ui/src/constants/trade.ts b/apps/csbie-ui/src/constants/trade.ts index 03118b0..3a725f9 100644 --- a/apps/csbie-ui/src/constants/trade.ts +++ b/apps/csbie-ui/src/constants/trade.ts @@ -34,12 +34,23 @@ export const cashOrderMarketOptions: Array<{ }> = [ { label: '自動', value: 'auto' }, { label: '東証', value: 'XTKS' }, + { label: '名証', value: 'XNGO' }, + { label: '福証', value: 'XFKA' }, + { label: '札証', value: 'XSAP' }, { label: 'NASDAQ', value: 'XNAS' }, { label: 'NYSE', value: 'XNYS' }, { label: 'NYSE Arca', value: 'ARCX' }, ] -export const searchableMarkets: CashOrderMarket[] = ['XTKS', 'XNAS', 'XNYS', 'ARCX'] +export const searchableMarkets: CashOrderMarket[] = [ + 'XTKS', + 'XNGO', + 'XFKA', + 'XSAP', + 'XNAS', + 'XNYS', + 'ARCX', +] export const tradeRouteIdFromStockId = (id: string) => { const separator = id.indexOf(':') diff --git a/apps/csbie-ui/src/features/history/HistoryView.vue b/apps/csbie-ui/src/features/history/HistoryView.vue index 697af42..d9120e2 100644 --- a/apps/csbie-ui/src/features/history/HistoryView.vue +++ b/apps/csbie-ui/src/features/history/HistoryView.vue @@ -1,21 +1,40 @@