Skip to content

Commit 6c28ae8

Browse files
committed
P1: B2B auth mode + bot enable + wait helpers + b2b aliases
1 parent ddc63a9 commit 6c28ae8

4 files changed

Lines changed: 226 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ You can provide these either:
103103
- `ETHORA_BASE_URL`: base host URL (example: `https://api.ethora.com`, `http://localhost:8080`)
104104
If provided, the server will default to `.../v1`.
105105
- `ETHORA_APP_JWT` (or `ETHORA_APP_TOKEN`): App JWT string, usually starting with `JWT ...`
106+
- `ETHORA_B2B_TOKEN`: B2B server token for `x-custom-token` auth (JWT with `type=server`)
106107

107108
> Security: **never** commit App JWTs to git. Configure them via env vars or the client’s secret store.
108109

src/apiClientDappros.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { appConfig, normalizeApiUrl } from "./config.js"
44
export const httpTokens = {
55
appJwt: appConfig.appJwt,
66
appToken: "",
7+
b2bToken: appConfig.b2bToken || "",
78
_token: '',
89
_refreshToken: '',
910
set refreshToken(token: string) {
@@ -21,7 +22,7 @@ export const httpTokens = {
2122
};
2223

2324
export const ethoraContext = {
24-
authMode: "user" as "user" | "app",
25+
authMode: "user" as "user" | "app" | "b2b",
2526
currentAppId: "" as string,
2627
}
2728

@@ -69,6 +70,16 @@ httpClientDappros.interceptors.request.use((config) => {
6970
return config;
7071
}
7172

73+
if (ethoraContext.authMode === "b2b") {
74+
if (!httpTokens.b2bToken) {
75+
throw new Error("B2B auth is selected but no b2bToken is configured. Set env ETHORA_B2B_TOKEN or call `ethora-configure` with b2bToken, or switch auth mode.")
76+
}
77+
// Backend expects `x-custom-token` for b2b/server/client flows.
78+
// Keep any existing Authorization header untouched.
79+
;(config.headers as any)["x-custom-token"] = httpTokens.b2bToken
80+
return config
81+
}
82+
7283
if (ethoraContext.authMode === "app") {
7384
if (!httpTokens.appToken) {
7485
throw new Error("App-token auth is selected but no appToken is configured. Call `ethora-app-select` with appToken, or call `ethora-configure` and set appToken.")
@@ -145,6 +156,7 @@ export function getClientState() {
145156
apiUrl: String(httpClientDappros.defaults.baseURL || ""),
146157
hasAppJwt: Boolean(httpTokens.appJwt),
147158
hasAppToken: Boolean(httpTokens.appToken),
159+
hasB2BToken: Boolean(httpTokens.b2bToken),
148160
hasUserToken: Boolean(httpTokens.token),
149161
hasRefreshToken: Boolean(httpTokens.refreshToken),
150162
authMode: ethoraContext.authMode,
@@ -170,11 +182,16 @@ export function selectApp(params: { appId: string; appToken?: string; authMode?:
170182
return getClientState()
171183
}
172184

173-
export function setAuthMode(authMode: "app" | "user") {
185+
export function setAuthMode(authMode: "app" | "user" | "b2b") {
174186
ethoraContext.authMode = authMode
175187
return getClientState()
176188
}
177189

190+
export function configureB2BToken(b2bToken: string) {
191+
httpTokens.b2bToken = String(b2bToken || "").trim()
192+
return getClientState()
193+
}
194+
178195
export function userRegistration(email: string, firstName: string, lastName: string) {
179196
return httpClientDappros.post(
180197
`/users/sign-up-with-email/`,

src/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@ export const appConfig = {
2020
// For login/register endpoints Ethora expects an App JWT. Never hardcode it in repo.
2121
// Provide it via env or via the `ethora-configure` tool.
2222
appJwt: process.env.ETHORA_APP_JWT ?? process.env.ETHORA_APP_TOKEN ?? "",
23+
24+
// For B2B/server flows Ethora expects x-custom-token (JWT with type=server).
25+
b2bToken: process.env.ETHORA_B2B_TOKEN ?? "",
2326
}

src/tools.ts

Lines changed: 203 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp"
22
import { CallToolResult } from "@modelcontextprotocol/sdk/types"
33
import z from "zod"
4-
import { appCreate, appCreateChat, appDelete, appDeleteChat, appGetDefaultRooms, appGetDefaultRoomsWithAppId, appList, appUpdate, apiPing, chatsBroadcastJobV2, chatsBroadcastV2, configureClient, filesDeleteV2, filesGetV2, filesUploadV2, getClientState, selectApp, setAuthMode, sourcesDocsDelete, sourcesDocsDeleteV2, sourcesDocsUpload, sourcesDocsUploadV2, sourcesSiteCrawl, sourcesSiteCrawlV2, sourcesSiteDeleteUrl, sourcesSiteDeleteUrlV2, sourcesSiteDeleteUrlV2Batch, sourcesSiteDeleteUrlV2Single, sourcesSiteReindex, sourcesSiteReindexV2, userLogin, userRegistration, walletERC20Transfer, walletGetBalance } from "./apiClientDappros.js"
4+
import { appCreate, appCreateChat, appDelete, appDeleteChat, appGetDefaultRooms, appGetDefaultRoomsWithAppId, appList, appUpdate, apiPing, chatsBroadcastJobV2, chatsBroadcastV2, configureB2BToken, configureClient, filesDeleteV2, filesGetV2, filesUploadV2, getClientState, selectApp, setAuthMode, sourcesDocsDelete, sourcesDocsDeleteV2, sourcesDocsUpload, sourcesDocsUploadV2, sourcesSiteCrawl, sourcesSiteCrawlV2, sourcesSiteDeleteUrl, sourcesSiteDeleteUrlV2, sourcesSiteDeleteUrlV2Batch, sourcesSiteDeleteUrlV2Single, sourcesSiteReindex, sourcesSiteReindexV2, userLogin, userRegistration, walletERC20Transfer, walletGetBalance } from "./apiClientDappros.js"
55
import { fail, ok } from "./mcpResponse.js"
66

77
function errorToText(error: unknown) {
@@ -68,12 +68,16 @@ function configureTool(server: McpServer) {
6868
inputSchema: {
6969
apiUrl: z.string().optional().describe("Ethora API URL (e.g. https://api.ethora.com/v1 or http://localhost:8080/v1)"),
7070
appJwt: z.string().optional().describe("Ethora App JWT (used for login/register endpoints)."),
71+
b2bToken: z.string().optional().describe("Ethora B2B token for x-custom-token auth (type=server)."),
7172
},
7273
},
73-
async function ({ apiUrl, appJwt }) {
74+
async function ({ apiUrl, appJwt, b2bToken }) {
7475
try {
7576
const state = configureClient({ apiUrl, appJwt })
76-
return asToolResult(ok(state, getDefaultMeta("ethora-configure")))
77+
if (typeof b2bToken === "string") {
78+
configureB2BToken(b2bToken)
79+
}
80+
return asToolResult(ok(getClientState(), getDefaultMeta("ethora-configure")))
7781
} catch (error) {
7882
return asToolResult(fail(error, getDefaultMeta("ethora-configure")))
7983
}
@@ -212,6 +216,32 @@ function authUseUserTool(server: McpServer) {
212216
)
213217
}
214218

219+
function authUseB2BTool(server: McpServer) {
220+
server.registerTool(
221+
"ethora-auth-use-b2b",
222+
{
223+
description: "Switch auth mode to B2B (x-custom-token) for subsequent API calls (requires b2bToken).",
224+
},
225+
async function () {
226+
try {
227+
return asToolResult(ok(setAuthMode("b2b"), getDefaultMeta("ethora-auth-use-b2b")))
228+
} catch (error) {
229+
return asToolResult(fail(error, getDefaultMeta("ethora-auth-use-b2b")))
230+
}
231+
}
232+
)
233+
}
234+
235+
function ensureB2BAuthForTool() {
236+
const state = getClientState() as any
237+
if (state.authMode !== "b2b") {
238+
throw new Error("This tool requires B2B auth. Call `ethora-auth-use-b2b` (and configure b2bToken via `ethora-configure`) first.")
239+
}
240+
if (!state.hasB2BToken) {
241+
throw new Error("B2B auth mode selected, but b2bToken is missing. Provide it via env ETHORA_B2B_TOKEN or `ethora-configure`.")
242+
}
243+
}
244+
215245
function appSelectTool(server: McpServer) {
216246
server.registerTool(
217247
"ethora-app-select",
@@ -284,6 +314,46 @@ function chatsBroadcastJobTool(server: McpServer) {
284314
)
285315
}
286316

317+
function sleep(ms: number) {
318+
return new Promise((resolve) => setTimeout(resolve, ms))
319+
}
320+
321+
function waitBroadcastJobTool(server: McpServer) {
322+
server.registerTool(
323+
"ethora-wait-broadcast-job-v2",
324+
{
325+
description: "Poll broadcast job status until completed/failed (app-token auth).",
326+
inputSchema: {
327+
jobId: z.string().min(1),
328+
timeoutMs: z.number().int().min(1000).max(300000).optional().describe("Max wait time (default 60000)"),
329+
intervalMs: z.number().int().min(250).max(10000).optional().describe("Poll interval (default 1000)"),
330+
},
331+
},
332+
async function ({ jobId, timeoutMs, intervalMs }) {
333+
const meta = getDefaultMeta("ethora-wait-broadcast-job-v2")
334+
try {
335+
ensureAppAuthForTool()
336+
const timeout = timeoutMs ?? 60_000
337+
const interval = intervalMs ?? 1_000
338+
const started = Date.now()
339+
let last: any = null
340+
while (Date.now() - started < timeout) {
341+
const res = await chatsBroadcastJobV2(jobId)
342+
last = res.data
343+
const state = String(last?.state || "")
344+
if (state === "completed" || state === "failed") {
345+
return asToolResult(ok({ done: true, state, job: last }, meta))
346+
}
347+
await sleep(interval)
348+
}
349+
return asToolResult(ok({ done: false, reason: "timeout", job: last }, meta))
350+
} catch (error) {
351+
return asToolResult(fail(error, meta))
352+
}
353+
}
354+
)
355+
}
356+
287357
function filesUploadV2Tool(server: McpServer) {
288358
server.registerTool(
289359
"ethora-files-upload-v2",
@@ -623,7 +693,7 @@ function appUpdateTool(server: McpServer) {
623693
domainName: z.string().optional().describe("If the domainName is set to 'abcd', your web application will be available at abcd.ethora.com."),
624694
appDescription: z.string().optional().describe("Set the application description"),
625695
primaryColor: z.string().optional().describe("Set thie color of the application in #F54927 format"),
626-
botStatus: z.enum(["on", "off"]).describe("Set the bot status to on or off, if on bot is enabled")
696+
botStatus: z.enum(["on", "off"]).optional().describe("Set the bot status to on or off, if on bot is enabled")
627697
}
628698
},
629699
async function ({ appId, displayName, domainName, appDescription, primaryColor, botStatus }) {
@@ -792,6 +862,130 @@ function walletERC20TransferTool(server: McpServer) {
792862
)
793863
}
794864

865+
function b2bAppCreateTool(server: McpServer) {
866+
server.registerTool(
867+
"ethora-b2b-app-create",
868+
{
869+
description: "Create a new app using B2B auth (x-custom-token).",
870+
inputSchema: {
871+
displayName: z.string().min(1),
872+
},
873+
},
874+
async function ({ displayName }) {
875+
try {
876+
ensureB2BAuthForTool()
877+
const res = await appCreate(displayName)
878+
return asToolResult(ok(res.data, getDefaultMeta("ethora-b2b-app-create")))
879+
} catch (error) {
880+
return asToolResult(fail(error, getDefaultMeta("ethora-b2b-app-create")))
881+
}
882+
}
883+
)
884+
}
885+
886+
function b2bBotEnableTool(server: McpServer) {
887+
server.registerTool(
888+
"ethora-b2b-bot-enable",
889+
{
890+
description: "Enable AI bot for an app (B2B auth). This triggers backend best-effort activation against configured AI service.",
891+
inputSchema: {
892+
appId: z.string().optional().describe("Defaults to current app if selected"),
893+
botTrigger: z.string().optional().describe("Optional bot trigger (example: '/bot' or 'any_message')"),
894+
},
895+
},
896+
async function ({ appId, botTrigger }) {
897+
try {
898+
ensureB2BAuthForTool()
899+
const state = getClientState() as any
900+
const effectiveAppId = appId || state.currentAppId
901+
if (!effectiveAppId) {
902+
throw new Error("appId is required (pass appId or call `ethora-app-select` first)")
903+
}
904+
const changes: any = { botStatus: "on" }
905+
if (botTrigger) changes.botTrigger = botTrigger
906+
const res = await appUpdate(effectiveAppId, changes)
907+
return asToolResult(ok(res.data, getDefaultMeta("ethora-b2b-bot-enable")))
908+
} catch (error) {
909+
return asToolResult(fail(error, getDefaultMeta("ethora-b2b-bot-enable")))
910+
}
911+
}
912+
)
913+
}
914+
915+
// Minimal namespace aliases to reduce auth-mode mistakes for agents.
916+
function b2bAliases(server: McpServer) {
917+
server.registerTool(
918+
"ethora.b2b.auth.use",
919+
{ description: "Alias for ethora-auth-use-b2b" },
920+
async function () {
921+
try {
922+
return asToolResult(ok(setAuthMode("b2b"), getDefaultMeta("ethora.b2b.auth.use")))
923+
} catch (error) {
924+
return asToolResult(fail(error, getDefaultMeta("ethora.b2b.auth.use")))
925+
}
926+
}
927+
)
928+
server.registerTool(
929+
"ethora.b2b.app.create",
930+
{ description: "Alias for ethora-b2b-app-create", inputSchema: { displayName: z.string().min(1) } },
931+
async function ({ displayName }) {
932+
try {
933+
ensureB2BAuthForTool()
934+
const res = await appCreate(displayName)
935+
return asToolResult(ok(res.data, getDefaultMeta("ethora.b2b.app.create")))
936+
} catch (error) {
937+
return asToolResult(fail(error, getDefaultMeta("ethora.b2b.app.create")))
938+
}
939+
}
940+
)
941+
server.registerTool(
942+
"ethora.b2b.bot.enable",
943+
{ description: "Alias for ethora-b2b-bot-enable", inputSchema: { appId: z.string().optional(), botTrigger: z.string().optional() } },
944+
async function ({ appId, botTrigger }) {
945+
try {
946+
ensureB2BAuthForTool()
947+
const state = getClientState() as any
948+
const effectiveAppId = appId || state.currentAppId
949+
if (!effectiveAppId) {
950+
throw new Error("appId is required (pass appId or call `ethora-app-select` first)")
951+
}
952+
const changes: any = { botStatus: "on" }
953+
if (botTrigger) changes.botTrigger = botTrigger
954+
const res = await appUpdate(effectiveAppId, changes)
955+
return asToolResult(ok(res.data, getDefaultMeta("ethora.b2b.bot.enable")))
956+
} catch (error) {
957+
return asToolResult(fail(error, getDefaultMeta("ethora.b2b.bot.enable")))
958+
}
959+
}
960+
)
961+
server.registerTool(
962+
"ethora.b2b.broadcast.wait",
963+
{ description: "Alias for ethora-wait-broadcast-job-v2", inputSchema: { jobId: z.string().min(1), timeoutMs: z.number().int().min(1000).max(300000).optional(), intervalMs: z.number().int().min(250).max(10000).optional() } },
964+
async function ({ jobId, timeoutMs, intervalMs }) {
965+
const meta = getDefaultMeta("ethora.b2b.broadcast.wait")
966+
try {
967+
ensureAppAuthForTool()
968+
const timeout = timeoutMs ?? 60_000
969+
const interval = intervalMs ?? 1_000
970+
const started = Date.now()
971+
let last: any = null
972+
while (Date.now() - started < timeout) {
973+
const res = await chatsBroadcastJobV2(jobId)
974+
last = res.data
975+
const state = String(last?.state || "")
976+
if (state === "completed" || state === "failed") {
977+
return asToolResult(ok({ done: true, state, job: last }, meta))
978+
}
979+
await sleep(interval)
980+
}
981+
return asToolResult(ok({ done: false, reason: "timeout", job: last }, meta))
982+
} catch (error) {
983+
return asToolResult(fail(error, meta))
984+
}
985+
}
986+
)
987+
}
988+
795989
function sourcesSiteCrawlV2AppTool(server: McpServer) {
796990
server.registerTool(
797991
"ethora-sources-site-crawl-v2",
@@ -938,9 +1132,11 @@ export function registerTools(server: McpServer) {
9381132
doctorTool(server);
9391133
authUseAppTool(server);
9401134
authUseUserTool(server);
1135+
authUseB2BTool(server);
9411136
appSelectTool(server);
9421137
chatsBroadcastTool(server);
9431138
chatsBroadcastJobTool(server);
1139+
waitBroadcastJobTool(server);
9441140
filesUploadV2Tool(server);
9451141
filesGetV2Tool(server);
9461142
filesDeleteV2Tool(server);
@@ -968,4 +1164,7 @@ export function registerTools(server: McpServer) {
9681164
getDefaultRoomsWithAppIdTool(server);
9691165
walletGetBalanceTool(server);
9701166
walletERC20TransferTool(server);
1167+
b2bAppCreateTool(server);
1168+
b2bBotEnableTool(server);
1169+
b2bAliases(server);
9711170
}

0 commit comments

Comments
 (0)