feat: expose scoped terminal screen reads to MCP

This commit is contained in:
bincxz
2026-09-12 17:06:08 +08:00
parent e7d7a12a91
commit b081673c2b
10 changed files with 153 additions and 16 deletions
+2 -2
View File
@@ -94,7 +94,7 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
|---------|-------------------|--------|
| Catty (sidebar) tools | `npm run generate:capability-tools` → `infrastructure/ai/harness/generated/cattyToolSpecs.json` | Sidebar agent tool set. CI verifies JSON drift. |
| Global agent tools | same script → `globalAgentToolSpecs.json` | Prepared for future global agent runtime; shared RPC tools only today. |
| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Harness tools are **not** on MCP. |
| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Only `harness.terminal.read_context` is also on MCP. |
| CLI | `electron/cli/netcatty-tool-cli.cjs` + `electron/capabilities/adapters/cliAdapter.cjs` | **30** catalog commands; exec/sftp/session remain special-case; vault/portforward/snippets use catalog fallback dispatch |
| RPC dispatch | `electron/bridges/mcpServerBridge.cjs` + `capabilityRpcDispatch.cjs` | `netcatty/*` builtin handlers via `buildBuiltinRpcHandlerRegistry` (catalog-aligned); `public/*`, `vault/*`, `portforward/*` → services |
| Vault bridge | `electron/bridges/aiBridge/vaultAgentBridge.cjs` + `infrastructure/ai/vaultAgentBridgeClient.ts` | Renderer vault state; **never** returns password/privateKey |
@@ -104,7 +104,7 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
**Handles:** `ToolOutputStore` persists across turns per chat session; cleared on chat session delete. Large `sftp.read` results spill to `tool_output_read`.
**Harness domain (`catalog/harness.cjs`):** Catty-only surface (`surfaces.catty.toolName`). Registered in the capability catalog but executed locally in `capabilityTools.executeLocalCattyCapability` (not MCP/CLI). `harness.web.search` is omitted when web search is not configured.
**Harness domain (`catalog/harness.cjs`):** Catty executes these locally in `capabilityTools.executeLocalCattyCapability`. The read-only `harness.terminal.read_context` also exposes `netcatty/readContext` to MCP, with scope validation before and after the existing renderer bridge call. It reuses the sidebar reader through the screen snapshot registry (80 rows by default, maximum 300); no script or command runs. Other harness tools remain Catty-only. `harness.web.search` is omitted when web search is not configured.
## Plugin host runtime (internal preview)
+2 -2
View File
@@ -2841,8 +2841,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
currentRow: buffer.baseY + buffer.cursorY,
lines,
};
});
}, [sessionId]);
}, readTerminalContext);
}, [readTerminalContext, sessionId]);
useEffect(() => {
const startHandler = (event: Event) => {
+22
View File
@@ -1817,6 +1817,27 @@ async function cancelWorkerBackgroundJobsForTerminalSession(sessionId) {
for (const jobId of matchingJobs) workerBackgroundJobs.delete(jobId);
}
// Read via the existing renderer bridge and the sidebar's bounded reader.
async function handleReadContext(params = {}) {
const scopedIds = resolveScopedSessionIds(params.chatSessionId, params.scopedSessionIds);
const sessionId = typeof params.sessionId === "string" && params.sessionId.trim()
? params.sessionId.trim()
: scopedIds?.length === 1 ? scopedIds[0] : null;
if (!sessionId) return { ok: false, error: "sessionId is required when the scope does not contain exactly one terminal." };
const scopeError = validateSessionScope(sessionId, params.chatSessionId, params.scopedSessionIds);
if (scopeError) return { ok: false, error: scopeError };
if (!invokeVaultAgentFn) return { ok: false, error: "Terminal context reader is unavailable." };
const result = await invokeVaultAgentFn("terminal.readContext", {
sessionId,
range: params.range,
startLine: params.startLine,
maxLines: params.maxLines,
});
// Scope can change while the renderer drains pending terminal output.
const currentScopeError = validateSessionScope(sessionId, params.chatSessionId, params.scopedSessionIds);
return currentScopeError ? { ok: false, error: currentScopeError } : result;
}
let builtinRpcHandlerRegistry = null;
function getBuiltinRpcHandlerRegistry() {
@@ -1826,6 +1847,7 @@ function getBuiltinRpcHandlerRegistry() {
"meta.status": handleGetStatus,
"attachment.list": handleListAttachments,
"attachment.read": handleReadAttachment,
"harness.terminal.read_context": handleReadContext,
"terminal.execute": handleExec,
"sftp.list": handleSftpList,
"sftp.read": handleSftpRead,
@@ -0,0 +1,60 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { listMcpTools } = require("../capabilities/codegen/toolSurfaces.cjs");
function setup(t, mode = "confirm") {
const path = require.resolve("./mcpServerBridge.cjs");
delete require.cache[path];
const bridge = require(path);
bridge.init({ sessions: new Map(), electronModule: null });
bridge.setPermissionMode(mode);
bridge.updateSessionMetadata([{ sessionId: "a" }, { sessionId: "b" }], "chat");
t.after(() => bridge.cleanup());
return bridge;
}
for (const mode of ["observer", "confirm", "auto"]) {
test(`screen reads work without approval in ${mode} mode`, async (t) => {
const bridge = setup(t, mode);
const calls = [];
bridge.setVaultAgentInvoker(async (op, params) => {
calls.push({ op, params });
return { ok: true, content: "existing output" };
});
const tool = listMcpTools().find((tool) => tool.mcpTool === "terminal_read_context");
const result = await bridge.dispatchBuiltinRpc(tool.rpcMethod, {
chatSessionId: "chat", sessionId: "b", range: "tail", maxLines: 10,
});
assert.equal(result.content, "existing output");
assert.deepEqual(calls, [{ op: "terminal.readContext", params: {
sessionId: "b", range: "tail", startLine: undefined, maxLines: 10,
} }]);
});
}
test("missing, ambiguous, empty and foreign scopes never reach the renderer", async (t) => {
const bridge = setup(t);
bridge.setVaultAgentInvoker(() => assert.fail("must not read"));
for (const params of [
{}, { sessionId: "a" }, { chatSessionId: "chat" },
{ chatSessionId: "chat", sessionId: "foreign" },
{ chatSessionId: "chat", sessionId: "a", scopedSessionIds: [] },
{ chatSessionId: "chat", sessionId: "foreign", scopedSessionIds: ["foreign"] },
]) assert.equal((await bridge.dispatchBuiltinRpc("netcatty/readContext", params)).ok, false);
});
test("one allowed terminal is inferred and revoked scope suppresses the result", async (t) => {
const bridge = setup(t);
bridge.setVaultAgentInvoker(async (_op, params) => ({ ok: true, sessionId: params.sessionId }));
const result = await bridge.dispatchBuiltinRpc("netcatty/readContext", {
chatSessionId: "chat", scopedSessionIds: ["b"],
});
assert.equal(result.sessionId, "b");
bridge.setVaultAgentInvoker(async () => {
bridge.updateSessionMetadata([], "chat");
return { ok: true, content: "do not disclose" };
});
const revoked = await bridge.dispatchBuiltinRpc("netcatty/readContext", {
chatSessionId: "chat", sessionId: "a",
});
assert.equal(revoked.ok, false);
assert.equal(revoked.content, undefined);
});
+3 -1
View File
@@ -2,7 +2,7 @@
const { CAPABILITY_STATUS } = require("../constants.cjs");
/** Catty-only harness tools (sidebar agent; renderer-local; not MCP/CLI). */
/** Renderer-local harness tools; terminal reading also exposes a scoped MCP entry. */
/** @type {import("../types.cjs").CapabilityDefinition[]} */
const HARNESS_CAPABILITIES = [
{
@@ -61,6 +61,7 @@ const HARNESS_CAPABILITIES = [
},
{
id: "harness.terminal.read_context",
agentKinds: ["sidebar"],
domain: "harness",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Read a bounded slice of the current terminal screen or scrollback from the active AI scope.",
@@ -75,6 +76,7 @@ const HARNESS_CAPABILITIES = [
},
surfaces: {
catty: { toolName: "terminal_read_context" },
builtin: { rpcMethod: "netcatty/readContext", mcpTool: "terminal_read_context" },
},
},
{
@@ -160,14 +160,15 @@ test("listAgentToolSpecs splits sidebar harness tools from shared RPC tools", ()
assert.ok(globalIds.every((id) => sidebarIds.includes(id) || id.startsWith("harness.") === false));
});
test("listCattyToolSpecs includes harness catty-only tools with local execution", () => {
test("listCattyToolSpecs includes renderer harness tools and the shared terminal read entry", () => {
const specs = listCattyToolSpecs();
assert.ok(specs.length >= 40);
const harness = specs.filter((spec) => spec.capabilityId.startsWith("harness."));
assert.equal(harness.length, 6);
for (const spec of harness) {
assert.equal(spec.localExecution, true);
assert.equal(spec.rpcMethod, null);
const screenRead = spec.capabilityId === "harness.terminal.read_context";
assert.equal(spec.localExecution, !screenRead);
assert.equal(spec.rpcMethod, screenRead ? "netcatty/readContext" : null);
}
const harnessIds = harness.map((spec) => spec.capabilityId);
assert.ok(harnessIds.includes("harness.tool_output.read"));
@@ -175,10 +176,11 @@ test("listCattyToolSpecs includes harness catty-only tools with local execution"
assert.ok(harnessIds.includes("harness.terminal.read_context"));
});
test("harness capabilities are not exposed on MCP", () => {
test("only terminal reading is exposed from harness on MCP", () => {
const mcpCapabilityIds = listMcpTools().map((tool) => tool.capabilityId);
assert.ok(mcpCapabilityIds.includes("harness.terminal.read_context"));
for (const capabilityId of mcpCapabilityIds) {
assert.ok(!capabilityId.startsWith("harness."));
assert.ok(!capabilityId.startsWith("harness.") || capabilityId === "harness.terminal.read_context");
}
});
@@ -2256,8 +2256,8 @@
{
"capabilityId": "harness.terminal.read_context",
"toolName": "terminal_read_context",
"rpcMethod": null,
"localExecution": true,
"rpcMethod": "netcatty/readContext",
"localExecution": false,
"description": "Read a bounded slice of the current terminal screen or scrollback from the active AI scope.",
"inputShape": {
"sessionId": {
@@ -2142,3 +2142,31 @@ describe('runSerializedVaultAgentRequest', () => {
assert.equal(value, 2);
});
});
describe('terminal.readContext', () => {
it('uses the registered bounded reader, preserving script snapshots and cleanup', async () => {
const { registerScreenSnapshotProvider, captureScreenSnapshot } = await import('../scripts/screenSnapshotRegistry');
const { buildTerminalContextReadResult } = await import('../../domain/terminalContextRead');
const snapshot = { rows: 2, cols: 80, currentRow: 1, lines: ['visible', 'prompt'] };
const lines = Array.from({ length: 500 }, (_, i) => `line ${i}`);
const dispose = registerScreenSnapshotProvider('read-test', () => snapshot, async (request) =>
buildTerminalContextReadResult({ ...request, fullText: lines.join('\n'), source: 'snapshot' }));
try {
assert.deepEqual(captureScreenSnapshot('read-test'), snapshot);
const result = await handleVaultAgentOp('terminal.readContext', {
sessionId: 'read-test', range: 'tail', maxLines: 100000,
}, createDeps());
assert.equal(result.ok, true);
assert.equal(result.returnedLines, 300);
assert.equal(result.startLine, 200);
assert.equal(result.source, 'snapshot');
const selected = await handleVaultAgentOp('terminal.readContext', {
sessionId: 'read-test', range: 'lines', startLine: 10, maxLines: 2,
}, createDeps());
assert.equal(selected.content, 'line 10\nline 11');
} finally {
dispose();
}
assert.equal((await handleVaultAgentOp('terminal.readContext', { sessionId: 'read-test' }, createDeps())).ok, false);
});
});
@@ -1,3 +1,5 @@
import { readScreenContext } from "../scripts/screenSnapshotRegistry";
import { normalizeTerminalContextRange } from "../../domain/terminalContextRead";
import { resolveHostOs } from '../../domain/host';
import type { GroupConfig, Host, Identity, KnownHost, ManagedSource, PortForwardingRule, ProxyProfile, Snippet, SSHKey, TerminalSettings, VaultNote } from '../../domain/models';
import type { RememberImportedKeyPassphraseResult } from '../../application/defaultKeyPassphrases';
@@ -570,6 +572,16 @@ export async function handleVaultAgentOp(
deps: VaultAgentApiDeps,
): Promise<Record<string, unknown>> {
switch (op) {
case 'terminal.readContext': {
const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '';
if (!sessionId) return { ok: false, error: 'sessionId is required.' };
return { ...await readScreenContext({
sessionId,
range: normalizeTerminalContextRange(params.range),
startLine: typeof params.startLine === 'number' ? params.startLine : undefined,
maxLines: typeof params.maxLines === 'number' ? params.maxLines : undefined,
}) };
}
case 'session.close': {
const sessionId = String(params.sessionId || '').trim();
if (!sessionId) return { ok: false, error: 'sessionId is required.' };
@@ -1,3 +1,5 @@
import type { TerminalContextReader } from "../../domain/terminalContextRead";
export type ScreenSnapshotProvider = () => {
rows: number;
cols: number;
@@ -7,15 +9,17 @@ export type ScreenSnapshotProvider = () => {
source?: string;
};
const providers = new Map<string, ScreenSnapshotProvider>();
const providers = new Map<string, { snapshot: ScreenSnapshotProvider; readContext?: TerminalContextReader }>();
export function registerScreenSnapshotProvider(
sessionId: string,
provider: ScreenSnapshotProvider,
readContext?: TerminalContextReader,
): () => void {
providers.set(sessionId, provider);
const entry = { snapshot: provider, readContext };
providers.set(sessionId, entry);
return () => {
if (providers.get(sessionId) === provider) {
if (providers.get(sessionId) === entry) {
providers.delete(sessionId);
}
};
@@ -31,5 +35,12 @@ export function captureScreenSnapshot(sessionId: string) {
lines: [] as string[],
};
}
return provider();
return provider.snapshot();
}
/** Uses the same bounded reader as the sidebar, including hibernated terminals. */
export const readScreenContext: TerminalContextReader = async (request) => {
const reader = providers.get(request.sessionId)?.readContext;
if (!reader) return { ok: false, error: "Terminal context reader is unavailable for this session." };
return reader(request);
};