feat: add NocoBase browser OAuth login
This commit is contained in:
+86
-15
@@ -4,14 +4,17 @@ import { fileURLToPath } from "node:url";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import {
|
||||
createCredentialProvider,
|
||||
credentialsPath,
|
||||
loadCredentials,
|
||||
loadStoredCredentials,
|
||||
NOCOBASE_API_BASE_URL,
|
||||
readHidden,
|
||||
saveCredentials,
|
||||
saveOAuthCredentials,
|
||||
} from "./credentials.js";
|
||||
import { CrmService } from "./crm-service.js";
|
||||
import { NocoBaseClient } from "./nocobase-client.js";
|
||||
import { loginWithBrowser } from "./oauth.js";
|
||||
import { ProjectService } from "./project-service.js";
|
||||
import { createServer } from "./tools.js";
|
||||
import { VERSION } from "./version.js";
|
||||
@@ -21,6 +24,8 @@ interface ParsedArgs {
|
||||
config?: string;
|
||||
enableWrites: boolean;
|
||||
json: boolean;
|
||||
manualKey: boolean;
|
||||
noBrowser: boolean;
|
||||
}
|
||||
|
||||
export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
||||
@@ -70,7 +75,7 @@ export function createRuntime(configPath: string): {
|
||||
projects: ProjectService;
|
||||
} {
|
||||
const config = loadConfig(configPath);
|
||||
const client = new NocoBaseClient(() => loadCredentials(), { dataSource: config.dataSource });
|
||||
const client = new NocoBaseClient(createCredentialProvider(), { dataSource: config.dataSource });
|
||||
return {
|
||||
client,
|
||||
crm: new CrmService(config, client),
|
||||
@@ -79,16 +84,48 @@ export function createRuntime(configPath: string): {
|
||||
}
|
||||
|
||||
async function setup(args: ParsedArgs): Promise<void> {
|
||||
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
||||
throw new Error("Setup requires a user-controlled local terminal; no files were changed");
|
||||
let authorizeKey: string;
|
||||
let authentication: "oauth-browser" | "personal-authorize-key-fallback";
|
||||
if (args.manualKey) {
|
||||
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
||||
throw new Error("Manual Key setup requires a user-controlled local terminal; no files were changed");
|
||||
}
|
||||
process.stderr.write(
|
||||
`This MCP connects only to ${NOCOBASE_API_BASE_URL}.\n` +
|
||||
"Enter a revocable personal Authorize Key only in the hidden prompt below.\n" +
|
||||
"Do not paste the Key into Agent chat.\n",
|
||||
);
|
||||
authorizeKey = (await readHidden("Personal NocoBase Authorize Key: ")).trim();
|
||||
if (!authorizeKey) throw new Error("Authorize Key must not be blank; no files were changed");
|
||||
authentication = "personal-authorize-key-fallback";
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`Signing in through ${NOCOBASE_API_BASE_URL} with NocoBase OAuth.\n` +
|
||||
"Your password and browser session stay inside NocoBase.\n",
|
||||
);
|
||||
const oauthCredentials = await loginWithBrowser({
|
||||
clientId: reusableOAuthClientId(),
|
||||
openBrowser: args.noBrowser ? async () => false : undefined,
|
||||
onAuthorization: ({ opened, url, userCode, expiresIn }) => {
|
||||
process.stderr.write(
|
||||
`${opened ? "Opened" : "Open"} the NocoBase authorization page in your browser:\n${url}\n` +
|
||||
`Confirmation code: ${userCode}\n` +
|
||||
`Complete sign-in within ${Math.ceil(expiresIn / 60)} minutes. Waiting for approval...\n`,
|
||||
);
|
||||
},
|
||||
});
|
||||
authorizeKey = oauthCredentials.accessToken;
|
||||
authentication = "oauth-browser";
|
||||
const verificationClient = new NocoBaseClient(() => ({
|
||||
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
||||
authorizeKey,
|
||||
}));
|
||||
await verificationClient.verifyCredentials();
|
||||
const configPath = ensureConfig(args.config);
|
||||
saveOAuthCredentials(oauthCredentials);
|
||||
await printSetupResult(args, configPath, authentication);
|
||||
return;
|
||||
}
|
||||
process.stderr.write(
|
||||
`This MCP connects only to ${NOCOBASE_API_BASE_URL}.\n` +
|
||||
"Create a revocable personal Authorize Key in NocoBase for your own business account.\n" +
|
||||
"Do not paste the Key into Agent chat. Enter it only in the hidden prompt below.\n",
|
||||
);
|
||||
const authorizeKey = (await readHidden("Personal NocoBase Authorize Key: ")).trim();
|
||||
if (!authorizeKey) throw new Error("Authorize Key must not be blank; no files were changed");
|
||||
const verificationClient = new NocoBaseClient(() => ({
|
||||
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
||||
authorizeKey,
|
||||
@@ -96,14 +133,26 @@ async function setup(args: ParsedArgs): Promise<void> {
|
||||
await verificationClient.verifyCredentials();
|
||||
const configPath = ensureConfig(args.config);
|
||||
saveCredentials({ authorizeKey });
|
||||
await printSetupResult(args, configPath, authentication);
|
||||
}
|
||||
|
||||
async function printSetupResult(
|
||||
args: ParsedArgs,
|
||||
configPath: string,
|
||||
authentication: "oauth-browser" | "personal-authorize-key-fallback",
|
||||
): Promise<void> {
|
||||
const runtime = createRuntime(configPath);
|
||||
const health = await runtime.crm.crmHealth();
|
||||
printResult(
|
||||
{
|
||||
ok: true,
|
||||
apiBaseUrl: NOCOBASE_API_BASE_URL,
|
||||
authentication,
|
||||
credentialsPath: credentialsPath(),
|
||||
keyExposure: "private-tty-and-0600-local-file-only",
|
||||
credentialExposure:
|
||||
authentication === "oauth-browser"
|
||||
? "nocobase-browser-and-0600-local-file-only"
|
||||
: "private-tty-and-0600-local-file-only",
|
||||
configPath,
|
||||
mode: args.enableWrites ? "fixed-project-writes" : "read-only",
|
||||
clientConfig: clientConfig(configPath, args.enableWrites),
|
||||
@@ -113,6 +162,13 @@ async function setup(args: ParsedArgs): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
function reusableOAuthClientId(): string | undefined {
|
||||
const path = credentialsPath();
|
||||
if (!existsSync(path)) return undefined;
|
||||
const stored = loadStoredCredentials(path);
|
||||
return stored.type === "oauth" ? stored.clientId : undefined;
|
||||
}
|
||||
|
||||
function ensureConfig(explicit?: string): string {
|
||||
if (explicit) {
|
||||
const path = resolve(explicit);
|
||||
@@ -159,13 +215,21 @@ function parseArgs(argv: string[]): ParsedArgs {
|
||||
command = first as ParsedArgs["command"];
|
||||
index = 1;
|
||||
}
|
||||
const parsed: ParsedArgs = { command, enableWrites: false, json: false };
|
||||
const parsed: ParsedArgs = {
|
||||
command,
|
||||
enableWrites: false,
|
||||
json: false,
|
||||
manualKey: false,
|
||||
noBrowser: false,
|
||||
};
|
||||
while (index < argv.length) {
|
||||
const flag = argv[index];
|
||||
if (flag === "--help" || flag === "-h") parsed.command = "help";
|
||||
else if (flag === "--version" || flag === "-v") parsed.command = "version";
|
||||
else if (flag === "--enable-writes") parsed.enableWrites = true;
|
||||
else if (flag === "--json") parsed.json = true;
|
||||
else if (flag === "--manual-key") parsed.manualKey = true;
|
||||
else if (flag === "--no-browser") parsed.noBrowser = true;
|
||||
else if (flag === "--config") {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("-")) throw new Error(`${flag} requires a value`);
|
||||
@@ -174,6 +238,11 @@ function parseArgs(argv: string[]): ParsedArgs {
|
||||
} else throw new Error(`Unknown option: ${flag}`);
|
||||
index += 1;
|
||||
}
|
||||
if (parsed.command !== "setup" && (parsed.manualKey || parsed.noBrowser)) {
|
||||
throw new Error("--manual-key and --no-browser are available only with setup");
|
||||
}
|
||||
if (parsed.manualKey && parsed.noBrowser)
|
||||
throw new Error("--manual-key cannot be combined with --no-browser");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -185,13 +254,15 @@ function helpText(): string {
|
||||
return `NocoBase CRM MCP ${VERSION}
|
||||
|
||||
Usage:
|
||||
nocobase-crm-mcp setup [--config <path>] [--json]
|
||||
nocobase-crm-mcp setup [--config <path>] [--no-browser] [--manual-key] [--json]
|
||||
nocobase-crm-mcp [serve] [--config <path>] [--enable-writes]
|
||||
nocobase-crm-mcp doctor [--config <path>] [--json]
|
||||
nocobase-crm-mcp client-config [--config <path>] [--enable-writes] [--json]
|
||||
|
||||
The NocoBase API endpoint is fixed to ${NOCOBASE_API_BASE_URL}.
|
||||
The setup command accepts the personal Authorize Key only through a hidden local TTY prompt.
|
||||
The setup command signs in through NocoBase OAuth in the user's browser.
|
||||
Use --no-browser to print the authorization URL without opening it.
|
||||
Use --manual-key only as a hidden local TTY fallback.
|
||||
The default stdio server is read-only.
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user