diff --git a/apps/csbie-server/src/routes/mcp.ts b/apps/csbie-server/src/routes/mcp.ts index b3e898b..b932552 100644 --- a/apps/csbie-server/src/routes/mcp.ts +++ b/apps/csbie-server/src/routes/mcp.ts @@ -194,14 +194,16 @@ const boardOptionsSchema = issueOptionsSchema.extend({ .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 stockOrderBaseSchema = z + .object({ + issueCode: issueCodeSchema, + market: marketCodeSchema, + side: tradeSideSchema, + accountType: accountTypeSchema.optional(), + quantity: z.number().positive().describe('Order quantity'), + depositType: depositTypeSchema.optional(), + }) + .strict() const cashOrderPriceConditionSchema = z.enum([ 'limit', @@ -218,9 +220,14 @@ const cashOrderPriceConditionSchema = z.enum([ const cashOrderSchema = stockOrderBaseSchema.extend({ preOrderMarket: marketCodeSchema .optional() - .describe('APK pre-order market for S-kabu; live S-kabu orders still send STK'), + .describe( + 'Original listed market for S-kabu checks, such as XTKS. For S-kabu live orders set kind: "s", market: "STK", and preOrderMarket to the listed market.', + ), price: z.number().positive().optional().describe('Order price for price-based orders'), - kind: z.enum(['market', 'limit', 'stop', 'oco', 'ifd', 'ifdo', 's', 'unknown']).optional(), + kind: z + .enum(['market', 'limit', 'stop', 'oco', 'ifd', 'ifdo', 's', 'unknown']) + .optional() + .describe('Use "s" for S-kabu fractional-share cash orders. Do not use sStock.'), priceCondition: cashOrderPriceConditionSchema .optional() .describe('APK/MTS execution condition for cash orders'), @@ -245,31 +252,42 @@ const cashOrderSchema = stockOrderBaseSchema.extend({ .describe('Previous market code sent with SOR orders; defaults to login profile'), }) -const cashOrderPreOrderSchema = z.object({ - issueCode: issueCodeSchema, - market: marketCodeSchema, - side: tradeSideSchema, - accountType: accountTypeSchema.optional(), - depositType: depositTypeSchema.optional(), - kind: z.enum(['s']).optional().describe('Requests APK S-kabu pre-order constraints'), - preOrderMarket: marketCodeSchema.optional().describe('APK pre-order market for S-kabu checks'), -}) +const cashOrderPreOrderSchema = z + .object({ + issueCode: issueCodeSchema, + market: marketCodeSchema, + side: tradeSideSchema, + accountType: accountTypeSchema.optional(), + depositType: depositTypeSchema.optional(), + kind: z + .enum(['s']) + .optional() + .describe('Set to "s" to request APK S-kabu pre-order constraints. Do not use sStock.'), + preOrderMarket: marketCodeSchema + .optional() + .describe('Original listed market for S-kabu preflight, such as XTKS.'), + }) + .strict() -const stockOrderMarginPositionSchema = z.object({ - openTradeDate: z - .string() - .describe('Open trade date from the margin position record, in yyyyMMdd or yyyy-MM-dd format'), - openPrice: z - .union([z.number().positive(), z.string().min(1)]) - .describe('Open price from the margin position record'), - quantity: z - .union([z.number().positive(), z.string().min(1)]) - .describe('Selected quantity from the margin position record'), - orgNewTradeDate: z.string().describe('Original new-trade date from the margin position record'), - bargainMarketCode: marketCodeSchema.describe( - 'Bargain market code from the margin position record', - ), -}) +const stockOrderMarginPositionSchema = z + .object({ + openTradeDate: z + .string() + .describe( + 'Open trade date from the margin position record, in yyyyMMdd or yyyy-MM-dd format', + ), + openPrice: z + .union([z.number().positive(), z.string().min(1)]) + .describe('Open price from the margin position record'), + quantity: z + .union([z.number().positive(), z.string().min(1)]) + .describe('Selected quantity from the margin position record'), + orgNewTradeDate: z.string().describe('Original new-trade date from the margin position record'), + bargainMarketCode: marketCodeSchema.describe( + 'Bargain market code from the margin position record', + ), + }) + .strict() const placeCashOrderSchema = cashOrderSchema.extend({ confirmationId: z @@ -283,48 +301,50 @@ const placeCashOrderSchema = cashOrderSchema.extend({ allowTrading: z.literal(true).optional().describe('Explicitly allows sending a live order'), }) -const orderCorrectionSchema = z.object({ - orderNumber: z - .string() - .min(1) - .optional() - .describe('Order number shown in order inquiry; required by the mobile pre-correction route'), - orderId: orderIdSchema, - issueCode: issueCodeSchema.optional().describe('Issue code from the pre-correction response'), - market: marketCodeSchema.optional().describe('Market code from the pre-correction response'), - tradeId: z.string().min(1).optional().describe('Original trade id code'), - correctionType: z.string().min(1).optional().describe('Additional correction flag'), - status: z.string().min(1).optional().describe('Original order status code'), - rbeOrderStatus: z.string().min(1).optional().describe('Original RBE order status code'), - depositTypeText: z.string().min(1).optional().describe('Display deposit type text'), - orderMethod: z.enum(['normal', 'stop', 'oco']).optional().describe('Primary order method'), - priceCondition: cashOrderPriceConditionSchema - .optional() - .describe('Corrected primary execution condition'), - triggerZone: z.enum(['above', 'below']).optional().describe('Stop trigger direction'), - triggerPrice: z.number().positive().optional().describe('Stop trigger price'), - secondaryPriceCondition: cashOrderPriceConditionSchema - .optional() - .describe('Secondary/OCO execution condition'), - secondaryPrice: z.number().positive().optional().describe('Secondary/OCO price'), - ifdPriceCondition: cashOrderPriceConditionSchema - .optional() - .describe('IFD follow-up execution condition for IF/IFDOCO correction'), - ifdPrice: z.number().positive().optional().describe('IFD follow-up price'), - ifdOrderMethod: z - .enum(['normal', 'stop', 'oco']) - .optional() - .describe('IFD follow-up special order method'), - ifdTriggerZone: z.enum(['above', 'below']).optional().describe('IFD stop trigger direction'), - ifdTriggerPrice: z.number().positive().optional().describe('IFD stop trigger price'), - ifdSecondaryPriceCondition: cashOrderPriceConditionSchema - .optional() - .describe('IFD secondary/OCO execution condition'), - ifdSecondaryPrice: z.number().positive().optional().describe('IFD secondary/OCO price'), - correctionControlFlag: z.enum(['1', '2']).optional().describe('Mobile correction control flag'), - quantity: z.number().positive().optional().describe('Corrected order quantity'), - price: z.number().positive().optional().describe('Corrected order price'), -}) +const orderCorrectionSchema = z + .object({ + orderNumber: z + .string() + .min(1) + .optional() + .describe('Order number shown in order inquiry; required by the mobile pre-correction route'), + orderId: orderIdSchema, + issueCode: issueCodeSchema.optional().describe('Issue code from the pre-correction response'), + market: marketCodeSchema.optional().describe('Market code from the pre-correction response'), + tradeId: z.string().min(1).optional().describe('Original trade id code'), + correctionType: z.string().min(1).optional().describe('Additional correction flag'), + status: z.string().min(1).optional().describe('Original order status code'), + rbeOrderStatus: z.string().min(1).optional().describe('Original RBE order status code'), + depositTypeText: z.string().min(1).optional().describe('Display deposit type text'), + orderMethod: z.enum(['normal', 'stop', 'oco']).optional().describe('Primary order method'), + priceCondition: cashOrderPriceConditionSchema + .optional() + .describe('Corrected primary execution condition'), + triggerZone: z.enum(['above', 'below']).optional().describe('Stop trigger direction'), + triggerPrice: z.number().positive().optional().describe('Stop trigger price'), + secondaryPriceCondition: cashOrderPriceConditionSchema + .optional() + .describe('Secondary/OCO execution condition'), + secondaryPrice: z.number().positive().optional().describe('Secondary/OCO price'), + ifdPriceCondition: cashOrderPriceConditionSchema + .optional() + .describe('IFD follow-up execution condition for IF/IFDOCO correction'), + ifdPrice: z.number().positive().optional().describe('IFD follow-up price'), + ifdOrderMethod: z + .enum(['normal', 'stop', 'oco']) + .optional() + .describe('IFD follow-up special order method'), + ifdTriggerZone: z.enum(['above', 'below']).optional().describe('IFD stop trigger direction'), + ifdTriggerPrice: z.number().positive().optional().describe('IFD stop trigger price'), + ifdSecondaryPriceCondition: cashOrderPriceConditionSchema + .optional() + .describe('IFD secondary/OCO execution condition'), + ifdSecondaryPrice: z.number().positive().optional().describe('IFD secondary/OCO price'), + correctionControlFlag: z.enum(['1', '2']).optional().describe('Mobile correction control flag'), + quantity: z.number().positive().optional().describe('Corrected order quantity'), + price: z.number().positive().optional().describe('Corrected order price'), + }) + .strict() const placeOrderCorrectionSchema = orderCorrectionSchema.extend({ allowTrading: z @@ -333,14 +353,16 @@ const placeOrderCorrectionSchema = orderCorrectionSchema.extend({ .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'), - 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'), -}) +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'), + }) + .strict() const placeOrderCancelSchema = orderCancelSchema.extend({ tradePassword: z.string().optional().describe('Trading password used by SBI'), @@ -423,24 +445,26 @@ const placeMarginCloseOrderSchema = marginCloseOrderSchema.extend({ .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'), - marginPositions: z - .array(stockOrderMarginPositionSchema) - .optional() - .describe('Margin position records selected for genbiki/genwatashi delivery'), - ippanMarginPaymentLimit: z - .string() - .optional() - .describe('APK ippan margin payment-limit code from board/pre-order information'), -}) +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'), + marginPositions: z + .array(stockOrderMarginPositionSchema) + .optional() + .describe('Margin position records selected for genbiki/genwatashi delivery'), + ippanMarginPaymentLimit: z + .string() + .optional() + .describe('APK ippan margin payment-limit code from board/pre-order information'), + }) + .strict() const actualDeliveryOrderPreOrderSchema = actualDeliveryOrderSchema.omit({ quantity: true, @@ -517,50 +541,58 @@ const placeIfdOrderSchema = ifdOrderSchema.extend({ 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'), - themeSetYyyymm: z - .string() - .length(6) - .describe('Theme set year/month (`theme_set_yyyymm`) from the mobile APK handoff'), - themeCourse: z - .union([z.number().int().nonnegative(), z.string().min(1).max(2)]) - .describe('Theme course (`theme_course`) from the mobile APK handoff'), - side: tradeSideSchema, - accountType: accountTypeSchema.optional().describe('Account/deposit type used for the order'), - depositType: accountTypeSchema.optional().describe('Deposit type used for the order'), - components: z - .array( - z.object({ - issueCode: issueCodeSchema, - quantity: z.union([z.number().positive(), z.string().min(1)]), - }), - ) - .min(1) - .max(10) - .describe('Component stock orders selected by the mobile theme investment flow'), - amount: z.number().positive().optional().describe('Order amount for the theme investment order'), -}) +const themeInvestmentOrderSchema = z + .object({ + themeId: z.string().min(1).describe('Theme ID for the theme investment order'), + themeSetYyyymm: z + .string() + .length(6) + .describe('Theme set year/month (`theme_set_yyyymm`) from the mobile APK handoff'), + themeCourse: z + .union([z.number().int().nonnegative(), z.string().min(1).max(2)]) + .describe('Theme course (`theme_course`) from the mobile APK handoff'), + side: tradeSideSchema, + accountType: accountTypeSchema.optional().describe('Account/deposit type used for the order'), + depositType: accountTypeSchema.optional().describe('Deposit type used for the order'), + components: z + .array( + z.object({ + issueCode: issueCodeSchema, + quantity: z.union([z.number().positive(), z.string().min(1)]), + }), + ) + .min(1) + .max(10) + .describe('Component stock orders selected by the mobile theme investment flow'), + amount: z + .number() + .positive() + .optional() + .describe('Order amount for the theme investment order'), + }) + .strict() -const themeInvestmentPreOrderSchema = z.object({ - themeId: z.string().min(1).describe('Theme ID from the mobile theme investment handoff'), - themeName: z - .string() - .min(1) - .optional() - .describe('Theme name from the mobile theme investment handoff'), - exchangeCode: marketCodeSchema.describe('Exchange code used by the mobile pre-order call'), - components: z - .array( - z.object({ - issueCode: issueCodeSchema, - quantity: z.union([z.number().positive(), z.string().min(1)]).optional(), - }), - ) - .min(1) - .max(10) - .describe('Component stocks selected by the mobile theme investment flow'), -}) +const themeInvestmentPreOrderSchema = z + .object({ + themeId: z.string().min(1).describe('Theme ID from the mobile theme investment handoff'), + themeName: z + .string() + .min(1) + .optional() + .describe('Theme name from the mobile theme investment handoff'), + exchangeCode: marketCodeSchema.describe('Exchange code used by the mobile pre-order call'), + components: z + .array( + z.object({ + issueCode: issueCodeSchema, + quantity: z.union([z.number().positive(), z.string().min(1)]).optional(), + }), + ) + .min(1) + .max(10) + .describe('Component stocks selected by the mobile theme investment flow'), + }) + .strict() const placeThemeInvestmentOrderSchema = themeInvestmentOrderSchema.extend({ allowTrading: z @@ -569,22 +601,24 @@ const placeThemeInvestmentOrderSchema = themeInvestmentOrderSchema.extend({ .describe('Explicitly allows sending a live theme investment order'), }) -const exchangeOrderSchema = z.object({ - currencyCode: z.string().min(1).describe('Currency code, such as USD'), - side: z.enum(['buy', 'sell']).describe('Buy or sell the foreign currency'), - tradeQuantity: z.union([z.number().positive(), z.string().min(1)]).describe('Order quantity'), - specificMethod: z - .enum(['foreign', 'domestic']) - .optional() - .describe('foreign for foreign-currency quantity, domestic for yen amount'), - accountKind: z.enum(['GENERAL', 'JR_NISA']).optional().describe('SBI exchange account kind'), - sellMethod: z.enum(['SELL_PART', 'SELL_ALL']).optional().describe('Required for sell orders'), - orderAmount: z - .union([z.number().positive(), z.string().min(1)]) - .optional() - .describe('Hidden order amount posted to SBI; required for domestic orders'), - tradePassword: z.string().optional().describe('Trading password used by SBI'), -}) +const exchangeOrderSchema = z + .object({ + currencyCode: z.string().min(1).describe('Currency code, such as USD'), + side: z.enum(['buy', 'sell']).describe('Buy or sell the foreign currency'), + tradeQuantity: z.union([z.number().positive(), z.string().min(1)]).describe('Order quantity'), + specificMethod: z + .enum(['foreign', 'domestic']) + .optional() + .describe('foreign for foreign-currency quantity, domestic for yen amount'), + accountKind: z.enum(['GENERAL', 'JR_NISA']).optional().describe('SBI exchange account kind'), + sellMethod: z.enum(['SELL_PART', 'SELL_ALL']).optional().describe('Required for sell orders'), + orderAmount: z + .union([z.number().positive(), z.string().min(1)]) + .optional() + .describe('Hidden order amount posted to SBI; required for domestic orders'), + tradePassword: z.string().optional().describe('Trading password used by SBI'), + }) + .strict() const exchangeRateSchema = exchangeOrderSchema.pick({ currencyCode: true, @@ -732,8 +766,57 @@ const getActions = Object.keys(getActionToMethod) as Array -const getActionSchema = z.enum(['capabilities', 'passkeys', ...getActions]) -const changeActionSchema = z.enum(changeActions) +const getActionDescription = `Available actions: capabilities, passkeys, ${getActions + .map((action) => `${action}=${getActionToMethod[action]}`) + .join( + ', ', + )}. S-kabu preflight uses cashOrderPreflight with input.kind: "s" and input.preOrderMarket, not sStock.` +const changeActionDescription = `Available actions: ${changeActions + .map((action) => `${action}=${changeActionToEstimateMethod[action]}`) + .join( + ', ', + )}. S-kabu cash orders use cashOrder with input.kind: "s", input.market: "STK", and input.preOrderMarket set to the listed market such as XTKS; do not use sStock, orderType, or sor.` + +const getToolInputSchema = z.discriminatedUnion('action', [ + z.object({ action: z.literal('capabilities') }).strict(), + z.object({ action: z.literal('passkeys') }).strict(), + ...getActions.map((action) => { + const method = getActionToMethod[action] + const paramsSchema = methodParamSchemas[method] + return z + .object({ + action: z.literal(action), + passkeyId: z.string().describe('Saved SBI passkey id'), + ...(paramsSchema ? { input: paramsSchema.describe(`${method} input`) } : {}), + }) + .strict() + }), +] as any) + +const changeToolInputSchema = z.discriminatedUnion( + 'action', + changeActions.map((action) => { + const method = changeActionToEstimateMethod[action] + const paramsSchema = methodParamSchemas[method] + if (!paramsSchema) throw new Error(`${method} must have an input schema`) + return z + .object({ + action: z.literal(action), + passkeyId: z.string().describe('Saved SBI passkey id'), + input: paramsSchema.describe(`${method} input`), + }) + .strict() + }) as any, +) + +type GetToolArgs = + | { action: 'capabilities' } + | { action: 'passkeys' } + | { + action: keyof typeof getActionToMethod + passkeyId: string + input?: unknown + } const parseMethodParams = (method: RpcMethod, input: unknown) => { const schema = methodParamSchemas[method] @@ -863,18 +946,12 @@ const createMcpServer = (c: Context) => { 'csbie-get', { title: 'Get SBI Data', - description: - 'Read SBI data through a small abstract action API. This tool never places, corrects, cancels, or otherwise changes real orders.', - inputSchema: { - 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'), - }, + description: `Read SBI data through a small abstract action API. This tool never places, corrects, cancels, or otherwise changes real orders. ${getActionDescription}`, + inputSchema: getToolInputSchema, }, - async ({ action, passkeyId, input }) => { + async (args) => { + const parsedArgs = getToolInputSchema.parse(args) as GetToolArgs + const { action } = parsedArgs requireAuthenticated(auth) if (action === 'capabilities') { @@ -890,6 +967,7 @@ const createMcpServer = (c: Context) => { const method = getActionToMethod[action] if (!method) throw new Error(`unsupported get action: ${String(action)}`) + const { passkeyId, input } = parsedArgs if (!passkeyId) throw new Error(`${action} requires passkeyId`) const params = parseMethodParams(method, input) @@ -901,16 +979,17 @@ const createMcpServer = (c: Context) => { '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'), - }, + 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. ${changeActionDescription}`, + inputSchema: changeToolInputSchema, + }, + async (args) => { + const { action, passkeyId, input } = changeToolInputSchema.parse(args) as { + action: keyof typeof changeActionToEstimateMethod + passkeyId: string + input: unknown + } + return textResult(await createChangeRequest(action, passkeyId, input)) }, - async ({ action, passkeyId, input }) => - textResult(await createChangeRequest(action, passkeyId, input)), ) server.registerTool(