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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,5 @@ deploy/terraform.tfstate*
.claude/settings.local.json
.mcp.json
skills-lock.json

.private/
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/).

## [1.2.2] - 2026-04-08

### Added
- Vertex AI service account JSON key authentication in `--setup` flow
- Google Vertex AI and Anthropic via Vertex AI as first-class setup options
- Auto-detection of `GOOGLE_APPLICATION_CREDENTIALS` environment variable during setup
- Keychain storage for Vertex credentials (project, location, key file path)

### Fixed
- `/analyze` endpoint now logs errors to stderr before responding (#37)

### Changed
- Fixed Vertex provider IDs (`google-vertex`, `anthropic-vertex`) to match registry keys
- Test fixtures replaced with smaller, cheaper PDFs (1-pager.pdf, attention-is-all-you-need.pdf)
- Test URL documents updated from IC datasheets to CS/ML papers, sorted by page count

## [1.2.1] - 2026-04-06

### Changed
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ Before committing:
npm run type-check && npm run lint && npm test
```

## Testing with PDFs

Always use `test/fixtures/1-pager.pdf` for MCP tool testing. It is small and cheap on LLM API calls. Never use `test/fixtures/oversized-doc.pdf` or other large PDFs unless the user gives explicit approval.

## Release Process

Branch protection requires releases to go through a PR:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@intelligentelectron/pdf-analyzer",
"version": "1.2.1",
"version": "1.2.2",
"description": "MCP server for analyzing PDF documents using AI (Google Gemini, Anthropic Claude, OpenAI)",
"type": "module",
"main": "dist/index.js",
Expand Down
161 changes: 135 additions & 26 deletions src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ import {
getModel,
setModel,
deleteAllCredentials,
deleteStoredValue,
deleteVertexCredentials,
setVertexProject,
setVertexLocation,
setVertexKeyFile,
} from "../keychain.js";
import { providerList } from "../providers/registry.js";
import { getSetupProviderList } from "../providers/registry.js";

/**
* Print version information.
Expand All @@ -36,14 +41,14 @@ export const printHelp = (): void => {
${BINARY_NAME} v${VERSION}

MCP server for analyzing PDF documents using AI.
Supports Google Gemini, Anthropic Claude, and OpenAI.
Supports Google Gemini, Google Vertex AI, Anthropic Claude, and OpenAI.

USAGE:
${BINARY_NAME} [OPTIONS]

OPTIONS:
--version, -v Print version and exit
--setup Choose an LLM provider and store your API key
--setup Choose an LLM provider and store credentials
--set-key Alias for --setup (deprecated)
--update Check for updates and apply if available
--uninstall Remove ${BINARY_NAME} from the system
Expand All @@ -52,9 +57,9 @@ OPTIONS:
PROVIDER SETUP:
${BINARY_NAME} --setup

Lets you choose a provider (Google Gemini, Anthropic Claude, or OpenAI)
and stores your API key in the OS credential store (macOS Keychain,
Windows Credential Manager, or Linux secret-tool).
Lets you choose a provider and stores credentials in the OS credential
store (macOS Keychain, Windows Credential Manager, or Linux secret-tool).
Vertex AI providers authenticate with a service account JSON key file.

INSTALLATION:
curl -fsSL https://raw.githubusercontent.com/${GITHUB_REPO}/main/install.sh | bash
Expand Down Expand Up @@ -85,17 +90,66 @@ function assertNotCancelled<T>(value: T | symbol): asserts value is T {
}

/**
* Handle the --setup flag: choose provider and store API key.
* Resolve a file path, expanding ~ to home directory and resolving to absolute.
*/
function resolveKeyFilePath(p: string): string {
if (p.startsWith("~")) {
p = path.join(os.homedir(), p.slice(1));
}
return path.resolve(p);
}

/**
* Check if a provider is a Vertex AI provider (uses service account auth, not API key).
*/
function isVertexProvider(id: string): boolean {
return id.includes("-vertex");
}

/**
* Validate a service account JSON key file at the given path.
* Returns an error message string if invalid, or undefined if valid.
*/
function validateKeyFile(value: string | undefined): string | undefined {
if (!value) return "Key file path is required";
const resolved = resolveKeyFilePath(value);
if (!fs.existsSync(resolved)) return `File not found: ${resolved}`;
try {
const content = JSON.parse(fs.readFileSync(resolved, "utf-8"));
if (content.type !== "service_account") {
return "Not a service account key (expected type: service_account)";
}
if (!content.project_id) return "Key file is missing project_id";
} catch {
return "File is not valid JSON";
}
}

/**
* Prompt user for a service account JSON key file path.
*/
function promptForKeyFile() {
return clack.text({
message: "Path to service account JSON key file",
placeholder: "/path/to/service-account-key.json",
validate: validateKeyFile,
});
}

/**
* Handle the --setup flag: choose provider and store credentials.
*/
export const handleSetupCommand = async (): Promise<void> => {
clack.intro("pdf-analyzer setup");

const allProviders = await getSetupProviderList();

const existingProvider = getActiveProvider();
const existingKey = getApiKey();
const existingModel = getModel();

if (existingProvider && existingKey) {
const providerConfig = providerList.find((p) => p.id === existingProvider);
if (existingProvider && (existingKey || isVertexProvider(existingProvider))) {
const providerConfig = allProviders.find((p) => p.id === existingProvider);
const providerName = providerConfig?.displayName ?? existingProvider;
const modelName =
providerConfig?.models.find((m) => m.id === existingModel)?.displayName ?? existingModel;
Expand All @@ -111,14 +165,14 @@ export const handleSetupCommand = async (): Promise<void> => {

const providerId = await clack.select({
message: "Choose your LLM provider",
options: providerList.map((p) => ({
options: allProviders.map((p) => ({
value: p.id,
label: p.displayName,
})),
});
assertNotCancelled(providerId);

const selected = providerList.find((p) => p.id === providerId)!;
const selected = allProviders.find((p) => p.id === providerId)!;

const modelId = await clack.select({
message: "Choose a model",
Expand All @@ -131,23 +185,78 @@ export const handleSetupCommand = async (): Promise<void> => {

const selectedModel = selected.models.find((m) => m.id === modelId)!;

clack.note(
`Model: ${selectedModel.displayName} (${modelId})\nGet your API key from: ${selected.apiKeyUrl}`,
selected.displayName
);
try {
if (isVertexProvider(selected.id)) {
// Vertex AI: detect GOOGLE_APPLICATION_CREDENTIALS or prompt for key file
let resolvedPath: string;

const envKeyFile = process.env.GOOGLE_APPLICATION_CREDENTIALS;
if (envKeyFile && !validateKeyFile(envKeyFile)) {
const resolved = resolveKeyFilePath(envKeyFile);
const useEnvFile = await clack.confirm({
message: `Detected GOOGLE_APPLICATION_CREDENTIALS: ${resolved}\n Use this service account key?`,
});
assertNotCancelled(useEnvFile);

if (useEnvFile) {
resolvedPath = resolved;
} else {
const keyFilePath = await promptForKeyFile();
assertNotCancelled(keyFilePath);
resolvedPath = resolveKeyFilePath(keyFilePath);
}
} else {
clack.note(
"Set GOOGLE_APPLICATION_CREDENTIALS in your shell to auto-detect next time.\nYou can also drag and drop the file into the terminal.",
"Service Account Key"
);
const keyFilePath = await promptForKeyFile();
assertNotCancelled(keyFilePath);
resolvedPath = resolveKeyFilePath(keyFilePath);
}

const key = await clack.password({
message: "Enter your API key",
validate: (value) => {
if (!value) return "API key is required";
},
});
assertNotCancelled(key);
const keyContent = JSON.parse(fs.readFileSync(resolvedPath, "utf-8"));

const project = await clack.text({
message: "Google Cloud project ID",
defaultValue: keyContent.project_id,
placeholder: keyContent.project_id,
});
assertNotCancelled(project);

const location = await clack.text({
message: "Vertex AI location",
defaultValue: "us-central1",
placeholder: "us-central1",
});
assertNotCancelled(location);

setActiveProvider(selected.id);
setModel(modelId);
setVertexKeyFile(resolvedPath);
setVertexProject(project);
setVertexLocation(location);
deleteStoredValue("API_KEY");
} else {
clack.note(
`Model: ${selectedModel.displayName} (${modelId})\nGet your API key from: ${selected.apiKeyUrl}`,
selected.displayName
);

const key = await clack.password({
message: "Enter your API key",
validate: (value) => {
if (!value) return "API key is required";
},
});
assertNotCancelled(key);

setActiveProvider(selected.id);
setModel(modelId);
setApiKey(key);
deleteVertexCredentials();
}

try {
setActiveProvider(selected.id);
setModel(modelId);
setApiKey(key);
clack.outro(`${selected.displayName} (${selectedModel.displayName}) configured successfully.`);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
Expand Down
45 changes: 44 additions & 1 deletion src/keychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ export function deleteStoredValue(account: string, service: string = DEFAULT_SER
const PROVIDER_ACCOUNT = "PROVIDER";
const API_KEY_ACCOUNT = "API_KEY";
const MODEL_ACCOUNT = "MODEL";
const VERTEX_PROJECT_ACCOUNT = "VERTEX_PROJECT";
const VERTEX_LOCATION_ACCOUNT = "VERTEX_LOCATION";
const VERTEX_KEY_FILE_ACCOUNT = "VERTEX_KEY_FILE";

/** Get the active provider ID. Returns null if not set. */
export function getActiveProvider(): string | null {
Expand Down Expand Up @@ -250,9 +253,49 @@ export function setModel(modelId: string): void {
setStoredValue(MODEL_ACCOUNT, modelId);
}

/** Delete all stored credentials (provider + API key + model). Best-effort. */
/** Get the stored Vertex AI project ID. Returns null if not set. */
export function getVertexProject(): string | null {
return getStoredValue(VERTEX_PROJECT_ACCOUNT);
}

/** Store the Vertex AI project ID. */
export function setVertexProject(value: string): void {
setStoredValue(VERTEX_PROJECT_ACCOUNT, value);
}

/** Get the stored Vertex AI location. Returns null if not set. */
export function getVertexLocation(): string | null {
return getStoredValue(VERTEX_LOCATION_ACCOUNT);
}

/** Store the Vertex AI location. */
export function setVertexLocation(value: string): void {
setStoredValue(VERTEX_LOCATION_ACCOUNT, value);
}

/** Get the stored Vertex AI service account key file path. Returns null if not set. */
export function getVertexKeyFile(): string | null {
return getStoredValue(VERTEX_KEY_FILE_ACCOUNT);
}

/** Store the Vertex AI service account key file path. */
export function setVertexKeyFile(value: string): void {
setStoredValue(VERTEX_KEY_FILE_ACCOUNT, value);
}

/** Delete Vertex-specific credentials (project, location, key file). Best-effort. */
export function deleteVertexCredentials(): void {
deleteStoredValue(VERTEX_PROJECT_ACCOUNT);
deleteStoredValue(VERTEX_LOCATION_ACCOUNT);
deleteStoredValue(VERTEX_KEY_FILE_ACCOUNT);
}

/** Delete all stored credentials (provider + API key + model + Vertex). Best-effort. */
export function deleteAllCredentials(): void {
deleteStoredValue(PROVIDER_ACCOUNT);
deleteStoredValue(API_KEY_ACCOUNT);
deleteStoredValue(MODEL_ACCOUNT);
deleteStoredValue(VERTEX_PROJECT_ACCOUNT);
deleteStoredValue(VERTEX_LOCATION_ACCOUNT);
deleteStoredValue(VERTEX_KEY_FILE_ACCOUNT);
}
4 changes: 2 additions & 2 deletions src/providers/anthropic-vertex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { anthropicVertexProvider } from "./anthropic-vertex.js";
import { anthropicProvider } from "./anthropic.js";

describe("anthropicVertexProvider", () => {
it('has id "anthropic" for provider-specific behavior', () => {
expect(anthropicVertexProvider.id).toBe("anthropic");
it('has id "anthropic-vertex" matching registry key', () => {
expect(anthropicVertexProvider.id).toBe("anthropic-vertex");
});

it("has same models as direct anthropic provider", () => {
Expand Down
5 changes: 3 additions & 2 deletions src/providers/anthropic-vertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,16 @@ function isTokenLimitError(error: unknown): boolean {
}

export const anthropicVertexProvider: ProviderConfig = {
id: "anthropic",
id: "anthropic-vertex",
displayName: "Anthropic via Vertex AI",
models: MODELS,
defaultModel: DEFAULT_MODEL,
apiKeyUrl: "",
createModel: (_apiKey: string, modelId: string) => {
createModel: (apiKey: string, modelId: string) => {
const client = createVertexAnthropic({
project: getProject(),
location: getLocation(),
...(apiKey ? { googleAuthOptions: { keyFile: apiKey } } : {}),
});
return client(modelId);
},
Expand Down
4 changes: 2 additions & 2 deletions src/providers/google-vertex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { vertexProvider } from "./google-vertex.js";
import { isGoogleTokenLimitError } from "./google-shared.js";

describe("vertexProvider", () => {
it('has id "google" for cached URI routing', () => {
expect(vertexProvider.id).toBe("google");
it('has id "google-vertex" matching registry key', () => {
expect(vertexProvider.id).toBe("google-vertex");
});

it("has 2 models", () => {
Expand Down
5 changes: 3 additions & 2 deletions src/providers/google-vertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,16 @@ async function preparePdf(source: PdfSource): Promise<PreparedPdf> {
}

export const vertexProvider: ProviderConfig = {
id: "google",
id: "google-vertex",
displayName: "Google Vertex AI",
models: GOOGLE_MODELS,
defaultModel: GOOGLE_DEFAULT_MODEL,
apiKeyUrl: "",
createModel: (_apiKey: string, modelId: string) => {
createModel: (apiKey: string, modelId: string) => {
const vertex = createVertex({
project: getProject(),
location: getLocation(),
...(apiKey ? { googleAuthOptions: { keyFile: apiKey } } : {}),
});
return vertex(modelId);
},
Expand Down
Loading
Loading