Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
17 changes: 17 additions & 0 deletions __tests__/unit/dotenv-quiet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { readFileSync } from 'fs';
import { resolve } from 'path';

const runtimeEntrypoints = [
['MCP stdio server', '../../src/index.ts'],
['standalone auth CLI', '../../src/auth-standalone.ts'],
['Claude install helper', '../../scripts/install-to-claude.js'],
] as const;

describe('dotenv runtime logging', () => {
it.each(runtimeEntrypoints)('%s loads dotenv quietly', (_name, relativePath) => {
const source = readFileSync(resolve(__dirname, relativePath), 'utf-8');

expect(source).toContain('config({ quiet: true })');
expect(source).not.toMatch(/\bconfig\(\s*\)/);
});
});
101 changes: 101 additions & 0 deletions __tests__/unit/notebook-tool-descriptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it, jest } from '@jest/globals';
import {
enrichToolsWithNotebookDescriptions,
resolveNotebookCacheForToolDescriptions,
} from '../../src/notebook-tool-descriptions';

const baseTools = [
{
name: 'evernote_create_note',
description: 'Create a note',
inputSchema: {
type: 'object',
properties: {
title: { type: 'string', description: 'Title' },
notebookName: { type: 'string', description: 'Name of notebook' },
},
},
},
{
name: 'evernote_update_note',
description: 'Update a note',
inputSchema: {
type: 'object',
properties: {
guid: { type: 'string', description: 'GUID' },
notebookName: { type: 'string', description: 'Move note to this notebook' },
},
},
},
{
name: 'evernote_search_notes',
description: 'Search notes',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Query' },
},
},
},
];

const notebooks = [
{ guid: 'personal-guid', name: 'Personal', defaultNotebook: true },
{ guid: 'work-guid', name: 'Work' },
];

describe('notebook-aware tool descriptions', () => {
it('loads notebooks through ensureAPI when tool discovery happens before any tool call', async () => {
const api = {};
const ensureAPI = jest.fn<() => Promise<object>>().mockResolvedValue(api);
const refreshNotebookCache = jest
.fn<(evernoteApi: object) => Promise<typeof notebooks>>()
.mockResolvedValue(notebooks);

const result = await resolveNotebookCacheForToolDescriptions({
currentCache: null,
currentApi: null,
ensureAPI,
refreshNotebookCache,
logError: jest.fn(),
});

expect(ensureAPI).toHaveBeenCalledTimes(1);
expect(refreshNotebookCache).toHaveBeenCalledWith(api);
expect(result).toEqual(notebooks);
});

it('keeps tool discovery available when API initialization fails', async () => {
const ensureAPI = jest.fn<() => Promise<object>>().mockRejectedValue(new Error('auth failed'));
const refreshNotebookCache = jest.fn<(evernoteApi: object) => Promise<typeof notebooks>>();

const result = await resolveNotebookCacheForToolDescriptions({
currentCache: null,
currentApi: null,
ensureAPI,
refreshNotebookCache,
logError: jest.fn(),
});

expect(result).toBeNull();
expect(refreshNotebookCache).not.toHaveBeenCalled();
});

it('injects live notebook names into create and update tool schemas', () => {
const enriched = enrichToolsWithNotebookDescriptions(baseTools as any, notebooks);
const createNoteTool = enriched.find((tool: any) => tool.name === 'evernote_create_note')!;
const updateNoteTool = enriched.find((tool: any) => tool.name === 'evernote_update_note')!;
const searchTool = enriched.find((tool: any) => tool.name === 'evernote_search_notes')!;
const createNotebookDescription = (createNoteTool.inputSchema as any).properties.notebookName.description;
const updateNotebookDescription = (updateNoteTool.inputSchema as any).properties.notebookName.description;

expect(createNotebookDescription).toContain(
'Available notebooks: "Personal", "Work".',
);
expect(createNotebookDescription).toContain(
'Default: "Personal".',
);
expect(updateNotebookDescription).toBe(createNotebookDescription);
expect((searchTool.inputSchema as any).properties).not.toHaveProperty('notebookName');
});
});
9 changes: 9 additions & 0 deletions __tests__/unit/tool-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import {
CreateNoteSchema,
SearchNotesSchema,
UpdateNoteSchema,
DeleteNoteSchema,
PatchNoteSchema,
GetNotebookSchema,
Expand Down Expand Up @@ -61,6 +62,14 @@ describe('tool schemas (M1)', () => {
});
});

describe('UpdateNoteSchema', () => {
it('rejects empty notebookName', () => {
expect(() =>
UpdateNoteSchema.parse({ guid: 'abc-123', notebookName: '' }),
).toThrow(/Notebook name cannot be empty/);
});
});

describe('DeleteNoteSchema', () => {
it('rejects missing guid', () => {
expect(() => DeleteNoteSchema.parse({})).toThrow();
Expand Down
4 changes: 2 additions & 2 deletions scripts/install-to-claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { detectEnvironment, getRecommendedSetup } from './detect-environment.js'
import { config } from 'dotenv';

// Load environment variables
config();
config({ quiet: true });

const rl = readline.createInterface({
input: process.stdin,
Expand Down Expand Up @@ -203,4 +203,4 @@ process.on('SIGINT', () => {
main().catch(error => {
console.error('Error:', error);
process.exit(1);
});
});
2 changes: 1 addition & 1 deletion src/auth-standalone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import * as readline from 'readline/promises';
import { stdin, stdout } from 'process';

// Load environment variables
config();
config({ quiet: true });

const tokenFile = path.join(process.cwd(), '.evernote-token.json');

Expand Down
111 changes: 97 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,21 @@ import { config } from 'dotenv';
import { ZodError } from 'zod';
import { EvernoteOAuth } from './oauth.js';
import { EvernoteAPI } from './evernote-api.js';
import { EvernoteConfig } from './types.js';
import { EvernoteConfig, NotebookInfo } from './types.js';
import { validateToolArgs } from './tool-schemas.js';
import { computeWebhookSignature } from './webhook.js';
import {
enrichToolsWithNotebookDescriptions,
resolveNotebookCacheForToolDescriptions,
} from './notebook-tool-descriptions.js';
import {
PollingChange,
buildWebhookPayload,
checkForPollingChanges,
} from './polling.js';

// Load environment variables
config();
config({ quiet: true });

// Validate required environment variables with clear instructions
const CONSUMER_KEY = process.env.EVERNOTE_CONSUMER_KEY;
Expand Down Expand Up @@ -73,6 +77,30 @@ const evernoteConfig: EvernoteConfig = {
const oauth = new EvernoteOAuth(evernoteConfig);
let api: EvernoteAPI | null = null;

// Notebook cache - populated after first successful API init
let notebookCache: NotebookInfo[] | null = null;

async function refreshNotebookCache(evernoteApi: EvernoteAPI): Promise<NotebookInfo[] | null> {
try {
notebookCache = await evernoteApi.listNotebooks();
console.error(`Notebook cache refreshed: ${notebookCache.length} notebooks`);
return notebookCache;
} catch (error: any) {
console.error(`Failed to refresh notebook cache: ${error.message}`);
return notebookCache;
Comment thread
jack-arturo marked this conversation as resolved.
Outdated
}
}

async function ensureAPIForToolDescriptions(): Promise<EvernoteAPI> {
try {
return await ensureAPI();
} catch (error) {
apiInitError = null;
lastInitAttempt = 0;
throw error;
}
Comment thread
jack-arturo marked this conversation as resolved.
}

// Initialize API on first use
let apiInitError: string | null = null;
let lastInitAttempt: number = 0;
Expand Down Expand Up @@ -109,6 +137,8 @@ async function ensureAPI(forceReinit: boolean = false): Promise<EvernoteAPI> {
api = new EvernoteAPI(client, tokens);
apiInitError = null;
console.error('API initialized successfully');
// Seed notebook cache in the background so descriptions are ready for ListTools
refreshNotebookCache(api).catch(() => {});
return api;
} catch (error: any) {
apiInitError = error.message || 'Failed to initialize Evernote API';
Expand Down Expand Up @@ -404,6 +434,10 @@ const tools: Tool[] = [
type: 'string',
description: 'New content (optional, Markdown supported)',
},
notebookName: {
type: 'string',
description: 'Move note to this notebook (optional)',
},
tags: {
type: 'array',
items: { type: 'string' },
Expand Down Expand Up @@ -748,11 +782,17 @@ const tools: Tool[] = [
},
];

// List tools handler
// List tools handler — injects live notebook names into descriptions when available
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: tools,
};
notebookCache = await resolveNotebookCacheForToolDescriptions({
currentCache: notebookCache,
currentApi: api,
ensureAPI: ensureAPIForToolDescriptions,
refreshNotebookCache,
logError: message => console.error(message),
});

return { tools: enrichToolsWithNotebookDescriptions(tools, notebookCache) };
});

// Call tool handler
Expand Down Expand Up @@ -906,16 +946,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
switch (name) {
case 'evernote_create_note': {
const { title, content, notebookName, tags } = validatedArgs;

// Find notebook GUID if name provided

let notebookGuid: string | undefined;
let notebookWarning: string | undefined;

if (notebookName) {
const notebooks = await evernoteApi.listNotebooks();
notebookCache = notebooks;
const notebook = notebooks.find(nb => nb.name === notebookName);
if (notebook) {
notebookGuid = notebook.guid;
} else {
throw new Error(`Notebook '${notebookName}' not found`);
// Notebook doesn't exist — auto-create it
try {
const newNotebook = await evernoteApi.createNotebook(notebookName);
notebookGuid = newNotebook.guid;
notebookWarning = `Notebook "${notebookName}" did not exist and was automatically created.`;
notebookCache = [...notebooks, { guid: newNotebook.guid, name: notebookName }];
} catch (createError: any) {
// Auto-create failed — fall back to the default notebook
const defaultNb = notebooks.find(nb => nb.defaultNotebook);
notebookGuid = defaultNb?.guid;
const fallbackName = defaultNb?.name || 'the default notebook';
notebookWarning =
`Notebook "${notebookName}" does not exist and could not be auto-created ` +
`(${createError.message}). Note was placed in "${fallbackName}" instead.`;
Comment thread
jack-arturo marked this conversation as resolved.
Outdated
}
}
}

Expand All @@ -926,11 +982,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
tagNames: tags,
});

const resultText = `Note created successfully!\nGUID: ${note.guid}\nTitle: ${note.title}`;
return {
content: [
{
type: 'text',
text: `Note created successfully!\nGUID: ${note.guid}\nTitle: ${note.title}`,
text: notebookWarning ? `${resultText}\n\n⚠️ ${notebookWarning}` : resultText,
},
],
};
Expand Down Expand Up @@ -1050,10 +1107,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}

case 'evernote_update_note': {
const { guid, title, content, tags, forceUpdate = false } = validatedArgs;
const { guid, title, content, notebookName, tags, forceUpdate = false } = validatedArgs;

console.error(`Updating note ${guid}`);

try {
// Get existing note
const note = await evernoteApi.getNote(guid, true, true);
Expand All @@ -1071,13 +1128,39 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
note.tagNames = tags;
}

let notebookWarning: string | undefined;
if (notebookName !== undefined) {
const notebooks = await evernoteApi.listNotebooks();
notebookCache = notebooks;
const notebook = notebooks.find(nb => nb.name === notebookName);
if (notebook) {
note.notebookGuid = notebook.guid;
} else {
Comment thread
jack-arturo marked this conversation as resolved.
// Auto-create the notebook
try {
const newNotebook = await evernoteApi.createNotebook(notebookName);
note.notebookGuid = newNotebook.guid;
notebookWarning = `Notebook "${notebookName}" did not exist and was automatically created.`;
notebookCache = [...notebooks, { guid: newNotebook.guid, name: notebookName }];
} catch (createError: any) {
const defaultNb = notebooks.find(nb => nb.defaultNotebook);
note.notebookGuid = defaultNb?.guid;
const fallbackName = defaultNb?.name || 'the default notebook';
notebookWarning =
`Notebook "${notebookName}" does not exist and could not be auto-created ` +
`(${createError.message}). Note was moved to "${fallbackName}" instead.`;
Comment thread
jack-arturo marked this conversation as resolved.
Outdated
}
}
}

const updatedNote = await evernoteApi.updateNote(note);

const resultText = `✅ Note updated successfully!\nGUID: ${updatedNote.guid}\nTitle: ${updatedNote.title}`;
return {
content: [
{
type: 'text',
text: `✅ Note updated successfully!\nGUID: ${updatedNote.guid}\nTitle: ${updatedNote.title}`,
text: notebookWarning ? `${resultText}\n\n⚠️ ${notebookWarning}` : resultText,
},
],
};
Expand Down
Loading
Loading