first commit

This commit is contained in:
Shotaro Nakamura
2026-06-18 22:59:53 +09:00
commit 20434dcd11
88 changed files with 13950 additions and 0 deletions
+604
View File
@@ -0,0 +1,604 @@
import { Hono } from 'hono'
import type { Context } from 'hono'
import { randomUUID } from 'node:crypto'
import { StreamableHTTPTransport } from '@hono/mcp'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
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 { connectSbi } from '../rpc/sbi-session'
import type { StoredSbiPasskeySecret } from './admin'
import {
assertAndConsumeApiKeyTradeLimits,
assertApiKeyMethodAllowed,
} from '../security/trade-limits'
import { readSecret } from '../security/keyring'
import { effectiveSbiDeviceId, effectiveSbiTradePassword } from '../security/sbi-credentials'
const jsonText = (value: unknown) => JSON.stringify(value, null, 2)
const textResult = (value: unknown) => ({
content: [{ type: 'text' as const, text: typeof value === 'string' ? value : jsonText(value) }],
})
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 = {
passkeyId: string
estimateMethod: RpcMethod
submitMethod: RpcMethod
params: unknown
confirmationId?: string
authKey: string
expiresAt: Date
}
const orderSubmitTickets = new Map<string, OrderSubmitTicket>()
const authKey = (auth: AuthContext) => {
if (auth.type === 'apiKey') return `apiKey:${auth.apiKeyId}`
if (auth.type === 'session') return `session:${auth.sessionId}`
return 'none'
}
const cleanupExpiredOrderSubmitTickets = (now = new Date()) => {
for (const [uuid, ticket] of orderSubmitTickets) {
if (ticket.expiresAt <= now) orderSubmitTickets.delete(uuid)
}
}
const orderSubmitMethodByEstimateMethod = {
'orders.cash.estimate': 'orders.cash.place',
'orders.cash.estimateCorrection': 'orders.cash.placeCorrection',
'orders.cash.estimateCorrectionConfirm': 'orders.cash.placeCorrection',
'orders.cash.estimateCancel': 'orders.cash.placeCancel',
'orders.margin.estimateOpen': 'orders.margin.open',
'orders.margin.estimateClose': 'orders.margin.close',
'orders.margin.estimateCloseSummary': 'orders.margin.closeSummary',
'orders.margin.estimateSummary': 'orders.margin.placeSummary',
'orders.margin.estimateActualDelivery': 'orders.margin.actualDelivery',
'orders.ifd.estimate': 'orders.ifd.place',
'orders.ifd.estimateCorrection': 'orders.ifd.placeCorrection',
'orders.ifd.estimateCancel': 'orders.ifd.placeCancel',
'orders.themeInvestment.estimate': 'orders.themeInvestment.place',
} as const satisfies Partial<Record<RpcMethod, RpcMethod>>
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 {
...value,
...(confirmationId ? { confirmationId } : {}),
allowTrading: true,
}
}
const confirmationIdFromPreview = (value: unknown) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const confirmationId = (value as Record<string, unknown>).confirmationId
return typeof confirmationId === 'string' && confirmationId ? confirmationId : undefined
}
const accountTypeSchema = z.enum(['general', 'specific', 'nisa', 'juniorNisa', 'unknown'])
const depositTypeSchema = z.enum(['general', 'specific', 'nisa', 'juniorNisa', 'unknown'])
const tradeSideSchema = z.enum(['buy', 'sell'])
const marketCodeSchema = z.string().min(1).describe('SBI market code')
const issueCodeSchema = z.string().min(1).describe('Issue code')
const orderIdSchema = z.string().min(1).describe('Order id')
const positionIdSchema = z.string().min(1).describe('Position id')
const pagingSchema = {
index: z.number().int().min(0).optional().describe('Start index for the result list'),
limit: z.number().int().positive().optional().describe('Maximum number of items to fetch'),
}
const issueOptionsSchema = z.object({
issueCode: issueCodeSchema,
market: marketCodeSchema.optional(),
})
const issueChartOptionsSchema = issueOptionsSchema.extend({
period: z.enum(['minute', 'day', 'week', 'month']).optional().describe('Chart period'),
unit: z
.number()
.int()
.positive()
.optional()
.describe('Candle unit. Minute charts accept 1, 5, 10, or 15; other periods use 1'),
count: z
.number()
.int()
.positive()
.max(9999)
.optional()
.describe('Number of historical prices to request'),
})
const issueSearchOptionsSchema = z.object({
query: z.string().min(1).describe('Search text, such as an issue code, name, or keyword'),
market: marketCodeSchema.optional().describe('Client-side market code filter'),
limit: z.number().int().positive().optional().describe('Maximum number of returned issues'),
})
const cashPositionOptionsSchema = z.object({
...pagingSchema,
issueCode: issueCodeSchema.optional(),
market: marketCodeSchema.optional(),
accountType: accountTypeSchema.optional(),
})
const marginPositionOptionsSchema = z.object({
...pagingSchema,
issueCode: issueCodeSchema.optional(),
market: marketCodeSchema.optional(),
side: tradeSideSchema.optional(),
accountType: accountTypeSchema.optional(),
})
const orderInquiryOptionsSchema = z.object({
...pagingSchema,
from: z.string().optional().describe('Start date for the inquiry range'),
to: z.string().optional().describe('End date for the inquiry range'),
issueCode: issueCodeSchema.optional(),
market: marketCodeSchema.optional(),
status: z.enum(['open', 'executed', 'cancelled', 'expired', 'rejected', 'unknown']).optional(),
})
const boardOptionsSchema = issueOptionsSchema.extend({
accountType: accountTypeSchema.optional(),
side: z
.enum([
'cashBuy',
'cashSell',
'marginOpen',
'marginOpenBuy',
'marginOpenSell',
'marginClose',
'marginCloseBuy',
'marginCloseSell',
])
.optional(),
})
const stockOrderBaseSchema = z.object({
issueCode: issueCodeSchema,
market: marketCodeSchema,
side: tradeSideSchema,
accountType: accountTypeSchema.optional(),
quantity: z.number().positive().describe('Order quantity'),
depositType: depositTypeSchema.optional(),
})
const cashOrderPriceConditionSchema = z.enum([
'limit',
'limitAtOpen',
'limitAtClose',
'limitIoc',
'market',
'marketAtOpen',
'marketAtClose',
'marketIoc',
'funari',
])
const cashOrderSchema = stockOrderBaseSchema.extend({
price: z.number().positive().optional().describe('Order price for price-based orders'),
kind: z.enum(['market', 'limit', 'stop', 'oco', 'ifd', 'ifdo', 's', 'unknown']).optional(),
priceCondition: cashOrderPriceConditionSchema
.optional()
.describe('APK/MTS execution condition for cash orders'),
orderTerm: z.enum(['day', 'week', 'date']).optional().describe('Order validity term'),
orderDate: z
.string()
.optional()
.describe('Validity date used when orderTerm is date, in yyyyMMdd or yyyy-MM-dd format'),
orderMethod: z.enum(['normal', 'stop', 'oco']).optional().describe('Special order method'),
triggerZone: z.enum(['above', 'below']).optional().describe('Stop trigger direction'),
triggerPrice: z.number().positive().optional().describe('Stop trigger price'),
secondaryPriceCondition: cashOrderPriceConditionSchema
.optional()
.describe('Secondary execution condition for OCO orders'),
secondaryPrice: z.number().positive().optional().describe('Secondary price for OCO orders'),
sorLastMarket: marketCodeSchema
.optional()
.describe('Previous market code sent with SOR orders; defaults to login profile'),
})
const placeCashOrderSchema = cashOrderSchema.extend({
confirmationId: z
.string()
.optional()
.describe('Confirmation ID returned by the confirmation step'),
allowTrading: z.literal(true).optional().describe('Explicitly allows sending a live order'),
})
const orderCorrectionSchema = z.object({
orderId: orderIdSchema,
quantity: z.number().positive().optional().describe('Corrected order quantity'),
price: z.number().positive().optional().describe('Corrected order price'),
})
const placeOrderCorrectionSchema = orderCorrectionSchema.extend({
allowTrading: z
.literal(true)
.optional()
.describe('Explicitly allows sending a live correction request'),
})
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'),
tradeId: z.string().min(1).optional().describe('Original trade id code'),
cancelType: z.string().min(1).optional().describe('Additional cancel flag'),
})
const placeOrderCancelSchema = orderCancelSchema.extend({
tradePassword: z.string().optional().describe('Trading password used by SBI'),
allowTrading: z
.literal(true)
.optional()
.describe('Explicitly allows sending a live cancellation request'),
})
const marginOpenOrderSchema = cashOrderSchema
const placeMarginOpenOrderSchema = marginOpenOrderSchema.extend({
confirmationId: z
.string()
.optional()
.describe('Confirmation ID returned by the confirmation step'),
allowTrading: z
.literal(true)
.optional()
.describe('Explicitly allows sending a live margin open order'),
})
const marginCloseOrderSchema = cashOrderSchema.extend({
positionId: positionIdSchema.optional().describe('Position ID to close'),
})
const placeMarginCloseOrderSchema = marginCloseOrderSchema.extend({
allowTrading: z
.literal(true)
.optional()
.describe('Explicitly allows sending a live margin close order'),
})
const actualDeliveryOrderSchema = z.object({
issueCode: issueCodeSchema,
market: marketCodeSchema,
accountType: accountTypeSchema.optional(),
quantity: z.number().positive().describe('Order quantity'),
depositType: depositTypeSchema.optional(),
price: z.number().positive().optional().describe('Order price for price-based requests'),
kind: z.enum(['genbiki', 'genwatashi']),
positionId: positionIdSchema.optional().describe('Position ID to deliver'),
})
const placeActualDeliveryOrderSchema = actualDeliveryOrderSchema.extend({
confirmationId: z
.string()
.optional()
.describe('Confirmation ID returned by the confirmation step'),
allowTrading: z
.literal(true)
.optional()
.describe('Explicitly allows sending a live actual-delivery order'),
})
const ifdOrderSchema = cashOrderSchema.extend({
tradeType: z
.enum(['cash', 'marginOpen'])
.optional()
.describe('Product to use for the first IFD leg'),
})
const placeIfdOrderSchema = ifdOrderSchema.extend({
confirmationId: z
.string()
.optional()
.describe('Confirmation ID returned by the confirmation step'),
allowTrading: z.literal(true).optional().describe('Explicitly allows sending a live IFD order'),
})
const themeInvestmentOrderSchema = z.object({
themeId: z.string().min(1).describe('Theme ID for the theme investment order'),
side: tradeSideSchema,
amount: z.number().positive().optional().describe('Order amount for the theme investment order'),
})
const placeThemeInvestmentOrderSchema = themeInvestmentOrderSchema.extend({
allowTrading: z
.literal(true)
.optional()
.describe('Explicitly allows sending a live theme investment order'),
})
const methodParamSchemas = {
'session.profile': undefined,
'account.profile': undefined,
'account.power.buyingPower': undefined,
'account.power.collateralRatio': undefined,
'account.positions.cash': cashPositionOptionsSchema.optional(),
'account.positions.cashDetail': cashPositionOptionsSchema.optional(),
'account.positions.cashForIssue': issueOptionsSchema,
'account.positions.margin': marginPositionOptionsSchema.optional(),
'account.positions.marginDetail': marginPositionOptionsSchema.optional(),
'account.positions.marginForIssue': issueOptionsSchema,
'account.positions.marginSummaryForIssue': issueOptionsSchema,
'account.positions.marginDetailsForIssue': issueOptionsSchema,
'account.positions.closeableMargin': marginPositionOptionsSchema,
'account.positions.deliverableMargin': marginPositionOptionsSchema,
'account.profitLoss.unrealized': undefined,
'market.issue.search': issueSearchOptionsSchema,
'market.issue.suggest': issueSearchOptionsSchema,
'market.issue.allowedPrices': issueOptionsSchema,
'market.issue.board': issueOptionsSchema,
'market.issue.chart': issueChartOptionsSchema,
'market.issue.openOrders': issueOptionsSchema,
'market.issue.tradingInfo': boardOptionsSchema,
'market.index.major': undefined,
'market.overview': undefined,
'market.ranking.market': undefined,
'market.ranking.sector': undefined,
'market.ranking.sbi': undefined,
'news.list': undefined,
'watchlist.list': undefined,
'orders.inquiry.executionsToday': orderInquiryOptionsSchema.optional(),
'orders.inquiry.open': orderInquiryOptionsSchema.optional(),
'orders.cash.estimate': cashOrderSchema,
'orders.cash.place': placeCashOrderSchema,
'orders.cash.estimateCorrection': orderCorrectionSchema,
'orders.cash.estimateCorrectionConfirm': orderCorrectionSchema,
'orders.cash.placeCorrection': placeOrderCorrectionSchema,
'orders.cash.estimateCancel': orderCancelSchema,
'orders.cash.placeCancel': placeOrderCancelSchema,
'orders.margin.estimateOpen': marginOpenOrderSchema,
'orders.margin.open': placeMarginOpenOrderSchema,
'orders.margin.estimateClose': marginCloseOrderSchema,
'orders.margin.close': placeMarginCloseOrderSchema,
'orders.margin.estimateCloseSummary': marginCloseOrderSchema,
'orders.margin.closeSummary': placeMarginCloseOrderSchema,
'orders.margin.estimateSummary': marginCloseOrderSchema,
'orders.margin.placeSummary': placeMarginCloseOrderSchema,
'orders.margin.estimateActualDelivery': actualDeliveryOrderSchema,
'orders.margin.actualDelivery': placeActualDeliveryOrderSchema,
'orders.ifd.estimate': ifdOrderSchema,
'orders.ifd.place': placeIfdOrderSchema,
'orders.ifd.estimateCorrection': orderCorrectionSchema,
'orders.ifd.placeCorrection': placeOrderCorrectionSchema,
'orders.ifd.estimateCancel': orderCorrectionSchema,
'orders.ifd.placeCancel': placeOrderCorrectionSchema,
'orders.themeInvestment.list': undefined,
'orders.themeInvestment.estimate': themeInvestmentOrderSchema,
'orders.themeInvestment.place': placeThemeInvestmentOrderSchema,
} satisfies Record<RpcMethod, z.ZodType | undefined>
const createMcpServer = (c: Context<AppBindings>) => {
const db = c.get('db')
const config = c.get('config')
const auth = c.get('auth')
const server = new McpServer({
name: 'csbie',
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',
})
},
)
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<StoredSbiPasskeySecret>(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) => {
requireAuthenticated(auth)
if (auth.type === 'apiKey') {
await assertApiKeyMethodAllowed(db, auth.apiKeyId, method)
}
if (isTradingMethod(method)) {
const tradingParams = params as { allowTrading?: boolean } | undefined
if (!tradingParams?.allowTrading) throw new Error('trading methods require allowTrading')
if (auth.type === 'apiKey') {
await assertAndConsumeApiKeyTradeLimits({
db,
apiKeyId: auth.apiKeyId,
params,
})
}
}
if (isCashOrderMethod(method)) {
const [passkey] = await db
.select({ keyringAccount: sbiPasskeys.keyringAccount })
.from(sbiPasskeys)
.where(eq(sbiPasskeys.id, passkeyId))
.limit(1)
if (!passkey) throw new Error('SBI passkey not found')
const secret = await readSecret<StoredSbiPasskeySecret>(passkey.keyringAccount)
if (!effectiveSbiDeviceId(secret)) {
throw new Error(
'orders.cash methods require an SBI deviceId registered with F1131. This passkey has no saved deviceId, so MCP cannot complete SBI trade authentication for cash order estimates or orders.',
)
}
if (!effectiveSbiTradePassword(secret)) {
throw new Error(
'orders.cash methods require a saved SBI tradePassword. This passkey has no saved tradePassword, so MCP cannot complete cash order estimates or orders.',
)
}
}
const client = await connectSbi(db, config, passkeyId)
const result = await invokeSbiMethod(client, method, params)
const submitMethod = submitMethodForEstimateMethod(method)
if (!submitMethod) return textResult(result)
cleanupExpiredOrderSubmitTickets()
const uuid = randomUUID()
const expiresAt = new Date(Date.now() + ORDER_SUBMIT_TICKET_TTL_MS)
const confirmationId = confirmationIdFromPreview(result)
orderSubmitTickets.set(uuid, {
passkeyId,
estimateMethod: method,
submitMethod,
params,
confirmationId,
authKey: authKey(auth),
expiresAt,
})
return textResult({
preview: result,
submit: {
uuid,
tool: 'csbie_sbi_submit_order',
expiresAt: expiresAt.toISOString(),
},
})
}
server.registerTool(
'csbie_sbi_submit_order',
{
title: 'Submit Estimated SBI Order',
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.',
inputSchema: {
uuid: z.string().uuid().describe('UUID returned by an order estimate tool'),
},
},
async ({ uuid }) => {
requireAuthenticated(auth)
cleanupExpiredOrderSubmitTickets()
const ticket = orderSubmitTickets.get(uuid)
if (!ticket) throw new Error('order submit uuid not found or expired')
if (ticket.authKey !== authKey(auth)) {
throw new Error('order submit uuid was created by a different authenticated caller')
}
orderSubmitTickets.delete(uuid)
return callSbiMethod(
ticket.submitMethod,
ticket.passkeyId,
orderSubmitParams(ticket.params, ticket.confirmationId),
)
},
)
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
}
export const createMcpRoutes = () => {
const app = new Hono<AppBindings>()
app.all('/', async (c) => {
if (!c.get('authenticated')) {
const resourceMetadata = new URL('/.well-known/oauth-protected-resource/api/mcp', c.req.url)
c.header('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadata.toString()}"`)
return c.json({ error: 'unauthorized' }, 401)
}
const transport = new StreamableHTTPTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
})
const server = createMcpServer(c)
try {
await server.connect(transport)
return await transport.handleRequest(c)
} finally {
await server.close()
await transport.close()
}
})
return app
}