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
63 changes: 38 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ when Knapper starts a test instance. Each private instance needs its own CLI soc
| macOS | `os.homedir()`, environment excluded | shared, only via a `HOME` override, unproven |
| Windows | `\\.\pipe\obsidian-cli-<username>` | **impossible** — no environment input at all |

Knapper refuses any launch that cannot isolate the private profile and CLI socket.
Knapper refuses any managed private-session launch that cannot isolate the private
profile and CLI socket.
It never routes a call to an unverified Obsidian process.

CI runs `ubuntu-latest` only, and covers lint, types, unit tests, and a packaged
Expand Down Expand Up @@ -202,9 +203,13 @@ Then point your client at `node /absolute/path/to/dist/cli.js`.
Start one session before you use Obsidian:

1. Call **`obsidian_session_open`** with the plugin source and ID when you need a private test session.
2. Call **`obsidian_status`** to confirm that the session is `self` and that the target is ready.
3. Apply the fixes that doctor names. These are usually **`obsidian_setup_cli`** and
**`obsidian_launch`** for the default profile. Private sessions start ready.
2. Call **`obsidian_session_status`** to check the session state and readiness.
3. Call **`obsidian_status`** to confirm that Knapper owns the target as `self`.
4. Apply the fixes that doctor names. These are usually **`obsidian_setup_cli`** and
**`obsidian_launch`** for the default profile.

Do not assume that an open session is ready. Retry the status checks after Knapper
reports a startup state, and use the reported remediation before you continue.

Then the development loop: **`obsidian_link_plugin`** to symlink your build output
into a vault, build, and **`obsidian_dev_cycle`** to reload the plugin and report
Expand Down Expand Up @@ -266,26 +271,30 @@ Knapper keeps the profile and `XDG_RUNTIME_DIR` private for managed sessions.

Set options via **environment variables** (and a subset via CLI flags). See [docs/configuration.md](docs/configuration.md) for examples.

| Setting | Env var | CLI flag | Default |
| ------------------- | ------------------------ | ---------------- | ----------------------------------------------- |
| CDP URL | `OBSIDIAN_CDP_URL` | `--cdp-url` | `http://127.0.0.1:9222` |
| Obsidian binary | `OBSIDIAN_BIN` | `--obsidian-bin` | OS default |
| Default vault | `OBSIDIAN_VAULT` | `--vault`, `-v` | (active / unset) |
| Toolsets | `KNAP_TOOLSETS` | `--toolsets` | core, UI, telemetry, plugin development, editor |
| knapper's disk root | `KNAP_HOME` | — | `~/.knapper_mcp` |
| Log level | `KNAP_LOG_LEVEL` | `--log-level` | `info` |
| Telemetry buffer | `KNAP_TELEMETRY_BUFFER` | — | `2000` |
| Network capture | `KNAP_TELEMETRY_NETWORK` | — | `false` |
| CDP reconnect delay | `KNAP_RECONNECT_MS` | — | `2000` |
| Screenshot dir | `KNAP_SCREENSHOT_DIR` | `--output-dir` | `./.knapper` |
| CLI timeout | `KNAP_CLI_TIMEOUT_MS` | — | `15000` |
| Session cleanup | `KNAP_IDLE_TIMEOUT_MS` | — | `86400000` (24 hours) |
| Activity ownership | `KNAP_ACTIVITY_IDLE_MS` | — | `300000` (5 minutes) |
| Command transport | `KNAP_COMMAND_TRANSPORT` | — | `auto` (`cli` or `playwright`) |
| Window match | `OBSIDIAN_TARGET_MATCH` | `--target-match` | (unset) |
| Transport | `MCP_TRANSPORT` | `--transport` | `stdio` |
| HTTP port | `MCP_PORT` | `--port` | `9223` |
| HTTP host | `MCP_HOST` | `--host` | `127.0.0.1` |
| Setting | Env var | CLI flag | Default |
| ------------------- | ------------------------ | ---------------- | --------------------------------------- |
| CDP URL | `OBSIDIAN_CDP_URL` | `--cdp-url` | `http://127.0.0.1:9222` |
| Obsidian binary | `OBSIDIAN_BIN` | `--obsidian-bin` | OS default |
| Default vault | `OBSIDIAN_VAULT` | `--vault`, `-v` | (active / unset) |
| Toolsets | `KNAP_TOOLSETS` | `--toolsets` | core, ui, telemetry, plugin-dev, editor |
| knapper's disk root | `KNAP_HOME` | — | `~/.knapper_mcp` |
| Log level | `KNAP_LOG_LEVEL` | `--log-level` | `info` |
| Telemetry buffer | `KNAP_TELEMETRY_BUFFER` | — | `2000` |
| Network capture | `KNAP_TELEMETRY_NETWORK` | — | `false` |
| CDP reconnect delay | `KNAP_RECONNECT_MS` | — | `2000` |
| Screenshot dir | `KNAP_SCREENSHOT_DIR` | `--output-dir` | `./.knapper` |
| CLI timeout | `KNAP_CLI_TIMEOUT_MS` | — | `15000` |
| Session cleanup | `KNAP_IDLE_TIMEOUT_MS` | — | `86400000` (24 hours) |
| Activity ownership | `KNAP_ACTIVITY_IDLE_MS` | — | `300000` (5 minutes) |
| Command transport | `KNAP_COMMAND_TRANSPORT` | — | `auto` (`cli` or `playwright`) |
| Window match | `OBSIDIAN_TARGET_MATCH` | `--target-match` | (unset) |
| Transport | `MCP_TRANSPORT` | `--transport` | `stdio` |
| HTTP port | `MCP_PORT` | `--port` | `9223` |
| HTTP host | `MCP_HOST` | `--host` | `127.0.0.1` |

The default `core` toolset includes raw Obsidian CLI dispatch and renderer
JavaScript evaluation. Set `KNAP_TOOLSETS` without `core` when a client must not
have these capabilities.

`LOG_LEVEL`, `RECONNECT_MS`, and `SCREENSHOT_DIR` are also accepted as aliases; the `KNAP_`-prefixed name wins when both are set.

Expand Down Expand Up @@ -327,6 +336,10 @@ list does not change during a connection. Do not change the tool list after star
The fixed surface includes session lifecycle, status, plugin development, telemetry,
editor, UI, and opt-in vault tools.

The session lifecycle tools are always available. They stay available when
`KNAP_TOOLSETS` excludes `core`, so an agent can open, inspect, release, or reset
the active session. `KNAP_TOOLSETS` controls the other toolsets at startup.

### Representative tools

**Core & provisioning:** `obsidian_status`, `obsidian_doctor`, `obsidian_launch`, `obsidian_setup_cli`, `obsidian_setup_vault`, `obsidian_link_plugin`, `obsidian_list_targets`, `obsidian_attach`, `obsidian_eval`, `obsidian_cli`, `obsidian_commands`, `obsidian_command`
Expand Down Expand Up @@ -441,7 +454,7 @@ npm run versions:check # CI gate: fail on drift
same flow as anything else:

```bash
git checkout -b release/v0.7.0-beta.1 dev
git checkout -b feature/release-v0.7.0-beta.1 dev
npm version 0.7.0-beta.1 --no-git-tag-version && npm run versions:sync
# PR into dev, then promote dev -> master
```
Expand Down
8 changes: 5 additions & 3 deletions commands/obsidian-doctor.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ Diagnose why knapper cannot talk to Obsidian and fix each layer explicitly.
- `CDP_PORT_CLOSED` → quit Obsidian completely, then `obsidian_launch` (single-instance lock)
- `ARGV_CORRUPTION` → edit `user-flags.conf` to use `--` prefixes
- `VAULT_NOT_FOUND` → fix `OBSIDIAN_VAULT` or register the vault in Obsidian
3. Call **`obsidian_status`** to confirm transports and the session owner state.
4. If CDP is still missing, verify nothing else holds port `9222` and that `OBSIDIAN_CDP_URL` matches your launch flags.
3. Call **`obsidian_session_status`** to confirm the active target and read its `cdpUrl`.
4. If an isolated session is active, use its `cdpUrl` for CDP checks.
5. If the default profile is active, use `OBSIDIAN_CDP_URL` for CDP checks when it is set.
Use port `9222` only when `OBSIDIAN_CDP_URL` is unset.

## Reference

Use skill **obsidian-instance-setup** for session lifecycle and multi-window attach (`obsidian_list_targets`, `obsidian_attach`).
Use skill **obsidian-instance-setup** for the single managed session lifecycle.
20 changes: 14 additions & 6 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,23 @@ hard-deletes the root.

## Tool surface

| Environment variable | CLI flag | Default | Purpose |
| --------------------- | -------------- | ----------------------------------------------- | ----------------------------- |
| `KNAP_TOOLSETS` | `--toolsets` | core, UI, telemetry, plugin development, editor | Startup toolset selection |
| `KNAP_SCREENSHOT_DIR` | `--output-dir` | `./.knapper` | Default-profile artifact root |
| Environment variable | CLI flag | Default | Purpose |
| --------------------- | -------------- | --------------------------------------- | ----------------------------- |
| `KNAP_TOOLSETS` | `--toolsets` | core, ui, telemetry, plugin-dev, editor | Startup toolset selection |
| `KNAP_SCREENSHOT_DIR` | `--output-dir` | `./.knapper` | Default-profile artifact root |

Knapper publishes the complete tool surface during MCP initialization. The list
does not change during a connection. Do not change the tool list after startup.
Knapper runs one operation at a time.

The session lifecycle tools are always available. They stay available when
`KNAP_TOOLSETS` excludes `core`, so an agent can open, inspect, release, or reset
the active session. `KNAP_TOOLSETS` controls the other toolsets at startup.

The default `core` toolset includes `obsidian_eval` and `obsidian_cli`. These tools
can run renderer JavaScript and raw Obsidian CLI commands. Remove `core` from an
explicit `KNAP_TOOLSETS` value when a client must not have those capabilities.

## Structured output

Knapper tools publish MCP output schemas. Successful calls return values through
Expand Down Expand Up @@ -105,8 +113,8 @@ The managed session uses `KNAP_HOME/telemetry/session.jsonl`. Knapper
writes redacted tool audit events under `KNAP_HOME/audit`. Audit files use mode
`0600` and have 14-day retention.

Session reset archives its telemetry in the quarantined root. Session release keeps
the telemetry file ready for the next agent that claims the same session.
Session reset archives its telemetry in the quarantined root. Session release does
not archive telemetry because it keeps the private session ready for reuse.

`LOG_LEVEL`, `RECONNECT_MS`, and `SCREENSHOT_DIR` are supported aliases. The
`KNAP_` name takes precedence.
Expand Down
41 changes: 38 additions & 3 deletions scripts/e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,38 @@ const liveHome = await createLiveHome("knapper-e2e-");
const client = new McpClient(["--toolsets", "all"], liveHome.env);
try {
const init = await client.initialize();
await check("a session opened after startup is diagnosed as isolated", async () => {
const dynamic = new McpClient(["--toolsets", "all"], liveHome.env);
try {
await dynamic.initialize();
const opened = await dynamic.call("obsidian_session_open", {
target: "isolated",
label: "e2e-scratch",
...(process.env.PLUGIN_SOURCE_DIR
? { pluginSourceDir: process.env.PLUGIN_SOURCE_DIR }
: {}),
...(process.env.PLUGIN_ID ? { pluginId: process.env.PLUGIN_ID } : {}),
});
assert(!opened.isError, `session open failed: ${opened.text}`);

const doctor = await dynamic.call("obsidian_doctor");
assert(!doctor.isError, `doctor returned an error: ${doctor.text}`);
assert(
/Active target: isolated/.test(doctor.text),
"doctor did not report an isolated target",
);
assert(doctor.json?.profile?.kind === "private", "doctor did not report a private profile");

const refused = await dynamic.call("obsidian_create_vault", {
path: join(liveHome.home, "should-refuse-vault"),
});
assert(refused.isError, "isolated session accepted a second vault");
assert(/isolated session/i.test(refused.text), `unexpected refusal: ${refused.text}`);
} finally {
await dynamic.call("obsidian_session_release").catch(() => undefined);
dynamic.close();
}
});
const isolated = await createDisposableWorkspace(client, root, {
home: liveHome.home,
label: "e2e-scratch",
Expand Down Expand Up @@ -1246,9 +1278,12 @@ try {

await check("the server exits promptly when the client closes stdin", async () => {
const short = new McpClient(["--toolsets", "core", "--vault", VAULT]);
await short.initialize();
await short.ok("obsidian_status");
short.close();
try {
await short.initialize();
await short.ok("obsidian_status");
} finally {
short.close();
}
const exited = await waitFor(() => short.exited, {
what: "the process to exit after stdin close",
timeoutMs: 12000,
Expand Down
8 changes: 7 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,13 @@ export async function createServerContext(config: Config): Promise<ServerContext
config.telemetryNetwork,
);
const browserProxy = new BrowserProxy(config, router, logger.child("browser"));
const activity = new ActivityGuard({ idleTimeoutMs: config.activityIdleMs });
const activity = new ActivityGuard({
idleTimeoutMs: config.activityIdleMs,
onError: (error) =>
logger.warn("activity ownership update failed", {
error: error instanceof Error ? error.message : String(error),
}),
});
let ctx!: ServerContext;
const managedSessionOpen = async (): Promise<boolean> => {
const descriptors = await listDescriptors();
Expand Down
2 changes: 1 addition & 1 deletion src/session/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ export async function waitSession(

throw new UobError("TIMEOUT", `Session ${key} is still starting.`, {
remediation:
"Retry obsidian_session_open after checking the launch diagnostics. Knapper cleans up the failed session before it returns the error.",
"Check the launch diagnostics. The session stays in the starting phase, so retry obsidian_session_open to keep waiting, or obsidian_session_reset to discard it.",
fixedBy: "obsidian_session_open",
details: await sessionDiagnostics(descriptor),
});
Expand Down
11 changes: 6 additions & 5 deletions src/tools/provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ export function registerProvisioningTools(ctx: ServerContext): void {
const targetVault = vaultArg ?? config.vault;

let health = await router.health({
skipCliProbe: config.sessionId === undefined,
skipCliProbe: ctx.targetKind !== "isolated",
});
const availability = await router.refreshAvailability(true);

Expand Down Expand Up @@ -414,7 +414,7 @@ export function registerProvisioningTools(ctx: ServerContext): void {
// Which instance this server drives, and how well CLI commands are pinned to
// it. `shared` means a CLI call may land in whichever Obsidian booted last,
// which is worth saying out loud because nothing else reports it.
if (config.sessionId !== undefined) {
if (ctx.targetKind === "isolated") {
lines.push(
"Active target: isolated",
...(full ? [`Profile: ${config.userDataDir}`] : []),
Expand Down Expand Up @@ -782,10 +782,11 @@ export function registerProvisioningTools(ctx: ServerContext): void {
.optional()
.describe("Cold-restart Obsidian afterwards so the vault is immediately usable"),
},
annotations: { readOnlyHint: false, destructiveHint: true },
handler: async (args) => {
const path = args.path as string;

if (config.sessionId !== undefined) {
if (ctx.targetKind === "isolated") {
throw new UobError(
"INVALID_ARGUMENT",
"An isolated session cannot add another vault to its private profile.",
Expand Down Expand Up @@ -898,7 +899,7 @@ export function registerProvisioningTools(ctx: ServerContext): void {
.string()
.describe("Registered vault name, or an absolute path to the vault directory"),
},
annotations: { destructiveHint: true },
annotations: { readOnlyHint: false, destructiveHint: true },
handler: async (args) => {
const wanted = args.vault as string;

Expand Down Expand Up @@ -948,7 +949,7 @@ export function registerProvisioningTools(ctx: ServerContext): void {
},
// Replaces an existing symlink at the target path, and unlink=true removes one.
// It refuses to clobber a real directory, but the link itself is still lost.
annotations: { destructiveHint: true },
annotations: { readOnlyHint: false, destructiveHint: true },
handler: async (args) => {
const vault = args.vault as string;
const sourceDir = args.sourceDir as string;
Expand Down
4 changes: 3 additions & 1 deletion src/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,10 @@ export class ToolRegistry {
* connection setup and error mapping live in exactly one place.
*/
bind(server: McpServer): void {
let registered = 0;
for (const def of this.definitions.values()) {
if (!this.isDefinitionEnabled(def)) continue;
registered += 1;
const config: Record<string, unknown> = { description: def.description };
const shape = def.jsonInputSchema
? jsonSchemaToZodShape(def.jsonInputSchema)
Expand Down Expand Up @@ -478,6 +480,6 @@ export class ToolRegistry {
}) as never,
);
}
this.logger.info(`registered ${this.definitions.size} tools`, this.byToolset());
this.logger.info(`registered ${registered} tools`, this.byToolset());
}
}
3 changes: 1 addition & 2 deletions src/tools/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,7 @@ export function registerSessionTools(ctx: ServerContext): void {
alwaysEnabled: true,
targetIndependent: true,
annotations: { readOnlyHint: true },
description:
"Report the active target and all legacy managed session records without changing them.",
description: "Report the active target and every managed session record without changing them.",
inputSchema: {},
handler: async () => {
const sessions = await listSessions({ currentKey: ctx.currentSessionKey });
Expand Down
Loading