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: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
"version": "2.53.0",
"version": "2.54.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
20 changes: 15 additions & 5 deletions apps/desktop/src/cli/commands/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
/**
* `zn mcp` — start the MCP server in stdio mode. The CLI process
* `zn mcp`: start the MCP server in stdio mode. The CLI process
* effectively becomes the MCP server for as long as the calling
* client (Claude Code, Claude Desktop, Codex) keeps the stdin pipe
* open. We delegate to the same runMcpServer() the legacy
* out/main/mcp.js entry uses, so behavior is identical.
* out/main/mcp.js entry uses; the one difference is which vault the
* tools run against. `zn mcp` honours `--vault`, `--server` and
* `--token` like every other command (#831), so an agent can be
* pointed at a vault other than the one the desktop app has open,
* or at a server, from its MCP client config alone. Without flags it
* follows the environment and then the app, as before. A flag that
* names nothing is reported on stderr at startup and the server still
* starts; the CLI's usual "resolve, then fail the command" would leave
* the MCP client with a dead server and no message.
*/

import { runMcpServer } from '../../mcp/server.js'
import type { ParsedArgs } from '../args.js'
import { resolveTarget } from '../vault-target.js'

export async function cmdMcp(): Promise<void> {
await runMcpServer()
export async function cmdMcp(args: ParsedArgs): Promise<void> {
await runMcpServer({ resolveTarget: () => resolveTarget(args) })
// The MCP SDK's connect() returns once stdin/stdout listeners are
// wired up. Node's event loop keeps the process alive while those
// listeners exist — but the CLI dispatcher would otherwise see this
// listeners exist, but the CLI dispatcher would otherwise see this
// promise resolve and call process.exit(0), tearing down stdin
// before the client can send any requests. Awaiting indefinitely
// here pins the process to whatever lifetime the parent client
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,11 @@ async function main(argv: string[]): Promise<number> {
// the subcommand before parsing flags so positionals don't include it.
const { subcommand, parsed } = peelSubcommand(command, rest)

// The MCP server gets the parsed flags rather than a backend: it must boot
// even when `--vault` names nothing yet, warning on stderr and retrying on
// each tool call, so the client sees a server rather than an exit (#831).
if (command === 'mcp') {
await cmdMcp()
await cmdMcp(parsed)
return 0
}

Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/cli/vault-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,10 @@ export async function resolveVaultTarget(
* The target when nothing named one: `ZENNOTES_SERVER` points at a server for
* a whole shell session, `ZENNOTES_VAULT` at a folder, and otherwise the vault
* the desktop app has open, a connected server included (#688). This is what
* `zn mcp` uses, so an agent works on the vault the user is looking at; the
* app's own token stays in the OS secret store, so a server that needs one
* gets it from `ZENNOTES_REMOTE_TOKEN` (or `--token`).
* `zn mcp` falls back to without `--vault` / `--server` (#831), so an agent
* works on the vault the user is looking at; the app's own token stays in the
* OS secret store, so a server that needs one gets it from
* `ZENNOTES_REMOTE_TOKEN` (or `--token`).
*/
export async function resolveDefaultTarget(
env: NodeJS.ProcessEnv = process.env,
Expand Down
187 changes: 185 additions & 2 deletions apps/desktop/src/mcp/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import { describe, expect, it } from 'vitest'
import { createServer, type Server as HttpServer } from 'node:http'
import { promises as fsp } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { parse } from '../cli/args'
import type { VaultBackend } from '../cli/backend'
import { resolveTarget, type VaultTarget } from '../cli/vault-target'
import { RemoteRequestError } from '../main/remote/connection'
import { callTool, commentAuthorForClient, describeToolError, listToolNames } from './server'
import {
callTool,
commentAuthorForClient,
describeToolError,
listToolNames,
runMcpServer,
type McpServerOptions
} from './server'

// Only the members a given test reaches are implemented; the cast keeps the
// stubs honest about being partial.
Expand Down Expand Up @@ -190,3 +205,171 @@ describe('comment tools (#738)', () => {
expect(stored[1]).toMatchObject({ parentId: 'c1', anchorText: 'Ship the beta in October.' })
})
})

/**
* `zn mcp --vault beta` used to serve the vault the desktop app had open
* (#831): the CLI parsed the flags and then started the server without them.
* These sessions run the real server over an in-memory transport with the
* real flag parser and target resolution, against a scratch config whose
* active vault is "alpha".
*/
describe('runMcpServer follows the target it is given (#831)', () => {
let tmpDir: string
let configDir: string
let alpha: string
let beta: string

async function writeConfig(config: Record<string, unknown>): Promise<void> {
await fsp.writeFile(path.join(configDir, 'zennotes.config.json'), JSON.stringify(config))
}

beforeAll(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'zen-mcp-831-'))
configDir = path.join(tmpDir, 'config')
alpha = path.join(tmpDir, 'alpha')
beta = path.join(tmpDir, 'beta')
await Promise.all(
[configDir, path.join(alpha, 'inbox'), path.join(beta, 'inbox')].map((dir) =>
fsp.mkdir(dir, { recursive: true })
)
)
})

afterAll(async () => {
await fsp.rm(tmpDir, { recursive: true, force: true })
})

beforeEach(async () => {
vi.stubEnv('ZENNOTES_CONFIG_DIR', configDir)
vi.stubEnv('ZENNOTES_VAULT', '')
vi.stubEnv('ZENNOTES_SERVER', '')
vi.stubEnv('ZENNOTES_REMOTE_TOKEN', '')
await writeConfig({
vaultRoot: alpha,
localVaults: [
{ root: alpha, name: 'alpha', lastOpenedAt: 2_000 },
{ root: beta, name: 'beta', lastOpenedAt: 1_000 }
]
})
})

afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})

/** A connected client for one server session; `vaultInfo` is what an agent
* sees when it calls the tool, `stderr` what the user sees at startup. */
async function session(options: Omit<McpServerOptions, 'transport'> = {}) {
const stderr: string[] = []
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
stderr.push(String(chunk))
return true
})
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await runMcpServer({ ...options, transport: serverTransport })
const client = new Client({ name: 'server-test', version: '0' })
await client.connect(clientTransport)
return {
stderr,
vaultInfo: async () => {
const result = await client.callTool({ name: 'vault_info', arguments: {} })
const text = (result.content as Array<{ text: string }>)[0].text
return result.isError ? { error: text } : { info: JSON.parse(text) as Record<string, unknown> }
},
close: () => client.close()
}
}

/** What `zn mcp <flags>` hands the server. */
const flags = (...argv: string[]) => ({ resolveTarget: () => resolveTarget(parse(argv)) })

it('without a target follows the vault the app has open, quietly', async () => {
const s = await session()
expect((await s.vaultInfo()).info).toMatchObject({ kind: 'local', vaultRoot: alpha })
expect(s.stderr).toEqual([])
await s.close()
})

it('serves the vault --vault names, by path or by known name', async () => {
const byPath = await session(flags('--vault', beta))
expect((await byPath.vaultInfo()).info).toMatchObject({ kind: 'local', vaultRoot: beta })
await byPath.close()

const byName = await session(flags('--vault', 'beta'))
expect((await byName.vaultInfo()).info).toMatchObject({ kind: 'local', vaultRoot: beta })
await byName.close()
})

it('tells the user at startup when --vault names nothing, and still serves', async () => {
const s = await session(flags('--vault', path.join(tmpDir, 'no-such-vault')))
// Said once, on stderr, before any tool call: that is where a terminal
// user and the MCP client's log see it.
expect(s.stderr).toHaveLength(1)
expect(s.stderr[0]).toContain('[zennotes-mcp] No vault named')
expect(s.stderr[0]).toContain('Known vaults: alpha, beta')
expect(s.stderr[0]).toContain('running anyway')
// The agent gets the same error instead of a silent fall-back to alpha.
const { error } = await s.vaultInfo()
expect(error).toContain('No vault named')
expect(error).toContain('alpha')
expect(s.stderr).toHaveLength(1)
await s.close()
})

it('reaches the server --server names and sends --token as its bearer token', async () => {
const seen: Array<{ url: string; auth: string | null }> = []
const fake: HttpServer = createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost')
seen.push({ url: url.pathname, auth: req.headers.authorization ?? null })
const bodies: Record<string, unknown> = {
'/api/vault': { root: '/srv/notes', name: 'notes' },
'/api/vault/settings': { primaryNotesLocation: 'inbox', systemFolderPaths: null },
'/api/folders': []
}
res.writeHead(url.pathname in bodies ? 200 : 404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(bodies[url.pathname] ?? null))
})
await new Promise<void>((resolve) => fake.listen(0, '127.0.0.1', resolve))
const address = fake.address()
if (address == null || typeof address === 'string') throw new Error('no port')
const baseUrl = `http://127.0.0.1:${address.port}`

try {
const s = await session(flags('--server', `127.0.0.1:${address.port}`, '--token', 'secret-831'))
const { info } = await s.vaultInfo()
expect(info).toMatchObject({ kind: 'remote', server: baseUrl, authConfigured: true })
expect(seen.length).toBeGreaterThan(0)
expect(seen.map((r) => r.auth)).toEqual(seen.map(() => 'Bearer secret-831'))
await s.close()
} finally {
await new Promise<void>((resolve) => fake.close(() => resolve()))
}
})

it('pins the first vault that resolves and retries only after a failure', async () => {
// One attempt at startup (warned), one per failing tool call, then the
// session keeps the first vault that resolved.
const outcomes: Array<Error | VaultTarget> = [
new Error('not at startup'),
new Error('not yet'),
{ kind: 'local', root: beta },
{ kind: 'local', root: alpha }
]
let calls = 0
const s = await session({
resolveTarget: async () => {
const next = outcomes[calls++]
if (next instanceof Error) throw next
return next
}
})
expect(s.stderr.join('')).toContain('not at startup')
expect((await s.vaultInfo()).error).toBe('Error: not yet')
expect((await s.vaultInfo()).info).toMatchObject({ vaultRoot: beta })
// A further call must not move the session to alpha: the target is pinned.
expect((await s.vaultInfo()).info).toMatchObject({ vaultRoot: beta })
expect(calls).toBe(3)
await s.close()
})
})
46 changes: 36 additions & 10 deletions apps/desktop/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Expand All @@ -19,7 +20,7 @@ import {

import { resolveInstructions } from './instructions-store.js'
import { createBackend, type VaultBackend } from '../cli/backend.js'
import { resolveDefaultTarget } from '../cli/vault-target.js'
import { resolveDefaultTarget, type VaultTarget } from '../cli/vault-target.js'
import { RemoteRequestError } from '../main/remote/connection.js'
import type { NoteFolder } from './vault-ops.js'
import { addComment, listCommentThreads, replyToComment, resolveComment } from './comment-ops.js'
Expand Down Expand Up @@ -965,23 +966,49 @@ export function describeToolError(err: unknown): string {
return message
}

export async function runMcpServer(): Promise<void> {
// Resolve the vault lazily and once: the workspace the desktop app has
// open, a folder or a server (#688). When nothing is configured yet we
// still boot so the client surface stays consistent; every tool call then
// reports the missing-vault error, and the next call tries again rather
// than repeating a stale failure.
export interface McpServerOptions {
/**
* Which vault the tools run against. `zn mcp` passes the target its
* `--vault` / `--server` / `--token` flags name (#831); the legacy stdio
* entry has no flags and follows the environment, then the workspace the
* desktop app has open (#688), which is also the default here.
*/
resolveTarget?: () => Promise<VaultTarget>
/** Defaults to stdio, the only transport the clients speak. Tests bind an
* in-memory pair instead. */
transport?: Transport
}

export async function runMcpServer(options: McpServerOptions = {}): Promise<void> {
const resolveTarget = options.resolveTarget ?? (() => resolveDefaultTarget())

// Resolve the vault once and keep it for the session. When nothing resolves
// yet we still boot so the client surface stays consistent; every tool call
// then reports the error, and the next call tries again rather than
// repeating a stale failure.
let backendPromise: Promise<VaultBackend> | null = null
const getBackend = (): Promise<VaultBackend> => {
if (!backendPromise) {
backendPromise = resolveDefaultTarget().then(createBackend)
backendPromise = resolveTarget().then(createBackend)
backendPromise.catch(() => {
backendPromise = null
})
}
return backendPromise
}

// Try at startup and say so on stderr when it fails: a terminal shows it at
// once and MCP clients keep it in their server logs, whereas waiting for the
// first tool call hid a `--vault` typo in a client config until the
// assistant tripped over it (#831). stdout is the protocol channel and
// stays clean.
await getBackend().catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
process.stderr.write(
`[zennotes-mcp] ${message} The MCP server is running anyway; every tool call returns this error until a vault resolves.\n`
)
})

const instructions = await resolveInstructions()
const server = new Server(
{ name: 'zennotes', version: '0.1.0' },
Expand Down Expand Up @@ -1022,6 +1049,5 @@ export async function runMcpServer(): Promise<void> {
}
})

const transport = new StdioServerTransport()
await server.connect(transport)
await server.connect(options.transport ?? new StdioServerTransport())
}
2 changes: 1 addition & 1 deletion apps/share-viewer/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/share-viewer",
"private": true,
"version": "2.53.0",
"version": "2.54.0",
"type": "module",
"description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website",
"homepage": "https://zennotes.org",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/web",
"private": true,
"version": "2.53.0",
"version": "2.54.0",
"type": "module",
"description": "ZenNotes web client for self-hosted and hosted deployments",
"homepage": "https://zennotes.org",
Expand Down
Loading
Loading