Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ DATABASE_URL=postgres://localhost:5432/workbench
# Hub origin the web dev server proxies /api to.
BASE_URL=http://localhost:3000
# PORT=3000
# Public hub origin OAuth sign-ins to MCP servers redirect back to; https
# unless loopback. Defaults to http://127.0.0.1:<PORT>.
# HUB_PUBLIC_URL=http://127.0.0.1:3000
# Origin sign-in trusts. Set it to the hub origin whenever the hub is not on
# :3000, or sign-up fails with "Invalid origin".
# BETTER_AUTH_BASE_URL=http://localhost:3000
Expand Down
5 changes: 3 additions & 2 deletions apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@
"@corbits/agent-token": "github:corbitsdev/corbits-agent-token#ed286f4592190f911de480ef5330ce03d8605f00",
"@corbits/artifacts": "github:corbitsdev/corbits-artifacts#4a2a3c22187991a6ce598faf794908fedd252e98",
"@corbits/codex-provider": "github:corbitsdev/corbits-codex-provider#a1f475f341261c05fa8dade0c82f1246c175b5b3",
"@corbits/credential-http": "^0.1.0",
"@corbits/cron": "0.2.1",
"@corbits/error-sink": "workspace:*",
"@corbits/mailbox": "github:corbitsdev/corbits-mailbox#65590a85fa143251b3ac20ba2eca92fc35e70e51",
"@corbits/mcp": "github:corbitsdev/corbits-mcp#9df1ab81d2f50d798bca2d2f44eb4759c92fac0e",
"@corbits/mcp": "^0.3.0",
"@corbits/memory": "github:corbitsdev/corbits-memory#8be608becc749fa0795a899826277823dca6db3e",
"@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#3028f8604155eee63f5a1711f0d4022d7984e3f7",
"@corbits/oauth-core": "^0.3.0",
"@corbits/webhooks": "github:corbitsdev/webhooks#3f4f83147fd826d3aec5bcac7ef4b0ae7f75c00d",
"@corbits/workflows": "workspace:*",
"@corbits/xai-provider": "github:corbitsdev/corbits-xai-provider#9f2d4bac40ea075df092fef807404a13c638cf14",
Expand Down
19 changes: 19 additions & 0 deletions apps/hub/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ import {
type WorkflowMemoryEnv,
} from "@corbits/memory";
import {
createOAuthLoginStore,
createOAuthTokenRefresher,
mountOAuthCallback,
mountOAuthLogin,
type OAuthLoginProviders,
} from "@corbits/oauth-core/hub";
Expand Down Expand Up @@ -738,6 +740,12 @@ export async function createHubServer({
app.route("/api/workflow-memory", workflowMemoryApi);
}

const hubPublicUrl = process.env["HUB_PUBLIC_URL"] ?? `http://127.0.0.1:${String(port)}`;
// A loopback hub is local development, where MCP servers may be plain-http
// loopback too; a public hub keeps discovery https-only.
const hubHost = new URL(hubPublicUrl).hostname;
const localDevHub = hubHost === "localhost" || hubHost.startsWith("127.") || hubHost === "[::1]";

{
// "Continue with Codex"/"Continue with xAI": the whole loopback PKCE
// flow runs here, so the verifier and the callback listener never
Expand Down Expand Up @@ -769,7 +777,17 @@ export async function createHubServer({
refresh: (refreshSecret: string, now: number) => refreshXaiTokens(refreshSecret, now),
},
};
// Resource-URL logins (MCP servers) redirect back to a hub-root route
// outside any tenant; it shares the login store with the tenant routes.
const oauthLoginStore = createOAuthLoginStore();
// The mount takes a plain Hono, so it rides its own router on the root.
const oauthCallbackApi = new Hono();
mountOAuthCallback(oauthCallbackApi, { store: oauthLoginStore, path: "/api/oauth/callback" });
app.route("/", oauthCallbackApi);
mountOAuthLogin(oauthLoginApi, {
store: oauthLoginStore,
callbackUrl: `${hubPublicUrl.replace(/\/+$/, "")}/api/oauth/callback`,
clientName: "Workbench",
db,
cipher: credentialCipher,
requireGrant: requireGrant("credential:*", "create"),
Expand Down Expand Up @@ -815,6 +833,7 @@ export async function createHubServer({
// catalog can be read is here, with the secret decrypted in-process.
const mcpApi = new Hono<TenantEnv>();
mountMcpDiscovery(mcpApi, {
allowLoopback: localDevHub,
db,
cipher: credentialCipher,
requireGrant: createRequireGrant({
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"@corbits/approvals": "workspace:*",
"@corbits/credential-mcp": "github:corbitsdev/corbits-credential-mcp#fb9ec3a6754348cd33c388c70c0830b8844c83de",
"@corbits/error-sink": "workspace:*",
"@corbits/mcp": "github:corbitsdev/corbits-mcp#9df1ab81d2f50d798bca2d2f44eb4759c92fac0e",
"@corbits/mcp": "^0.3.0",
"@corbits/react-ui": "0.3.0",
"@corbits/url-path": "workspace:*",
"@corbits/worker": "workspace:*",
Expand Down
127 changes: 116 additions & 11 deletions apps/web/src/mcp-servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export async function listMcpServers(

/** A provider row per server, pinned to that server's origin so the resolved
* handle can only ever reach the host it was added for. */
async function ensureMcpProvider(
export async function ensureMcpProvider(
tenantId: string,
handle: string,
url: string,
Expand Down Expand Up @@ -296,32 +296,137 @@ export async function addMcpServer(
},
fetchImpl,
);
return recordCatalog(
{
tenantId: input.tenantId,
credentialId,
providerId,
handle: input.handle,
name: input.name,
url: input.url,
auth,
existingMetadata: {},
},
fetchImpl,
);
}

/** Reads the catalog with the stored credential and writes it onto that
* credential's metadata beside whatever the credential already carries (an
* OAuth login keeps its client id and token URL there). The credential is
* dropped if either step fails, so a failed add leaves nothing behind. */
async function recordCatalog(
args: {
readonly tenantId: string;
readonly credentialId: string;
readonly providerId: string;
readonly handle: string;
readonly name: string;
readonly url: string;
readonly auth: McpAuthKind;
readonly existingMetadata: Record<string, unknown>;
},
fetchImpl: typeof fetch,
): Promise<McpServer> {
const { tenantId, credentialId, providerId, handle, name, url, auth } = args;
let tools: readonly McpTool[];
try {
tools = await discoverMcpCatalog(
{ tenantId: input.tenantId, url: input.url, credentialId },
fetchImpl,
);
tools = await discoverMcpCatalog({ tenantId, url, credentialId }, fetchImpl);
} catch (cause) {
await deleteCredential(input.tenantId, credentialId, fetchImpl);
await deleteCredential(tenantId, credentialId, fetchImpl);
throw cause;
}
const mcp = { handle: input.handle, name: input.name, url: input.url, auth, tools };
const mcp = { handle, name, url, auth, tools };
const patched = await fetchImpl(
tenantPath(input.tenantId, `/credentials/${encodeURIComponent(credentialId)}`),
tenantPath(tenantId, `/credentials/${encodeURIComponent(credentialId)}`),
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ metadata: { mcp } }),
body: JSON.stringify({ metadata: { ...args.existingMetadata, mcp } }),
},
);
if (!patched.ok) {
await deleteCredential(input.tenantId, credentialId, fetchImpl);
throw new McpServerError(`recording the ${input.handle} server's tools failed`);
await deleteCredential(tenantId, credentialId, fetchImpl);
throw new McpServerError(`recording the ${handle} server's tools failed`);
}
return { credentialId, providerId, ...mcp };
}

const LoginStarted = type({ loginId: "string", authorizeUrl: "string" });
const LoginStatus = type({
status: "'pending' | 'completed' | 'failed' | 'cancelled'",
"credentialId?": "string",
"message?": "string",
});
export type McpLoginStatus = typeof LoginStatus.infer;

export type McpSignInInput = {
readonly tenantId: string;
readonly handle: string;
readonly name: string;
readonly url: string;
};

/** Starts the hub-side OAuth login for a server: the provider row first (the
* login stores its credential on it), then the authorize URL to open. */
export async function startMcpSignIn(
input: McpSignInInput,
fetchImpl: typeof fetch = fetch,
): Promise<{
readonly loginId: string;
readonly authorizeUrl: string;
readonly providerId: string;
}> {
const providerId = await ensureMcpProvider(input.tenantId, input.handle, input.url, fetchImpl);
const started = await fetchImpl(tenantPath(input.tenantId, "/oauth-logins"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
resourceUrl: input.url,
providerId,
credentialName: mcpCredentialName(input.handle),
}),
});
if (!started.ok) {
throw new McpServerError(`starting the ${input.name} sign-in failed`);
}
return { ...(await readJson(started, LoginStarted, "the sign-in")), providerId };
}

export async function readMcpSignIn(
tenantId: string,
loginId: string,
fetchImpl: typeof fetch = fetch,
): Promise<McpLoginStatus> {
const response = await fetchImpl(
tenantPath(tenantId, `/oauth-logins/${encodeURIComponent(loginId)}`),
);
if (!response.ok) {
throw new McpServerError("checking the sign-in failed");
}
return readJson(response, LoginStatus, "the sign-in status");
}

/** Finishes a completed sign-in: the login already stored an `oauth_token`
* credential, so this only reads the server's catalog with it and records the
* catalog beside the credential's OAuth metadata. */
export async function addSignedInMcpServer(
input: McpSignInInput & { readonly credentialId: string; readonly providerId: string },
fetchImpl: typeof fetch = fetch,
): Promise<McpServer> {
const credential = (await listCredentials(input.tenantId, fetchImpl)).find(
(row) => row.id === input.credentialId,
);
return recordCatalog(
{
...input,
auth: "oauth",
existingMetadata: credential?.metadata ?? {},
},
fetchImpl,
);
}

/** Drops a server and the provider row that existed only for it. */
export async function removeMcpServer(
args: {
Expand Down
Loading
Loading