pi-soly 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ask/index.ts +0 -11
- package/ask/picker.ts +281 -176
- package/ask/tests/picker.test.ts +177 -148
- package/codemap.ts +276 -0
- package/hotreload.ts +239 -0
- package/init.ts +302 -0
- package/mcp/CHANGELOG.md +384 -0
- package/mcp/LICENSE +21 -0
- package/mcp/OAUTH.md +355 -0
- package/mcp/README.md +410 -0
- package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
- package/mcp/__tests__/cli.upstream-test.ts +97 -0
- package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
- package/mcp/__tests__/config.upstream-test.ts +303 -0
- package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
- package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
- package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
- package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
- package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
- package/mcp/__tests__/errors.upstream-test.ts +218 -0
- package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
- package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
- package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
- package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
- package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
- package/mcp/__tests__/logger.upstream-test.ts +175 -0
- package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
- package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
- package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
- package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
- package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
- package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
- package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
- package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
- package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
- package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
- package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
- package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
- package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
- package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
- package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
- package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
- package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
- package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
- package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
- package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
- package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
- package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
- package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
- package/mcp/agent-dir.ts +20 -0
- package/mcp/app-bridge.bundle.js +67 -0
- package/mcp/cli.js +184 -0
- package/mcp/commands.ts +422 -0
- package/mcp/config.ts +666 -0
- package/mcp/consent-manager.ts +64 -0
- package/mcp/direct-tools.ts +439 -0
- package/mcp/elicitation-handler.ts +347 -0
- package/mcp/errors.ts +219 -0
- package/mcp/glimpse-ui.ts +80 -0
- package/mcp/host-html-template.ts +427 -0
- package/mcp/index.ts +362 -0
- package/mcp/init.ts +362 -0
- package/mcp/lifecycle.ts +93 -0
- package/mcp/logger.ts +169 -0
- package/mcp/mcp-auth-flow.ts +559 -0
- package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
- package/mcp/mcp-auth.ts +302 -0
- package/mcp/mcp-auth.upstream-test.ts +373 -0
- package/mcp/mcp-callback-server.ts +372 -0
- package/mcp/mcp-callback-server.upstream-test.ts +416 -0
- package/mcp/mcp-oauth-provider.ts +369 -0
- package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
- package/mcp/mcp-panel.ts +829 -0
- package/mcp/mcp-setup-panel.ts +580 -0
- package/mcp/metadata-cache.ts +201 -0
- package/mcp/notify.ts +111 -0
- package/mcp/npx-resolver.ts +424 -0
- package/mcp/oauth-handler.ts +57 -0
- package/mcp/onboarding-state.ts +68 -0
- package/mcp/package.json +106 -0
- package/mcp/panel-keys.ts +37 -0
- package/mcp/proxy-modes.ts +949 -0
- package/mcp/resource-tools.ts +17 -0
- package/mcp/sampling-handler.ts +268 -0
- package/mcp/server-manager.ts +545 -0
- package/mcp/state.ts +41 -0
- package/mcp/tool-metadata.ts +216 -0
- package/mcp/tool-registrar.ts +46 -0
- package/mcp/tool-result-renderer.ts +161 -0
- package/mcp/types.ts +448 -0
- package/mcp/ui-resource-handler.ts +146 -0
- package/mcp/ui-server.ts +623 -0
- package/mcp/ui-session.ts +386 -0
- package/mcp/ui-stream-types.ts +89 -0
- package/mcp/utils.ts +129 -0
- package/mcp/vitest.config.ts +14 -0
- package/migrate.ts +258 -0
- package/notification.ts +218 -0
- package/notifications-log.ts +83 -0
- package/package.json +20 -3
- package/status.ts +140 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
function writeJson(path: string, value: unknown): void {
|
|
7
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
8
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const mocks = {
|
|
12
|
+
createMcpPanel: vi.fn(),
|
|
13
|
+
createMcpSetupPanel: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
vi.mock("../mcp-panel.ts", () => ({
|
|
17
|
+
createMcpPanel: mocks.createMcpPanel,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("../mcp-setup-panel.ts", () => ({
|
|
21
|
+
createMcpSetupPanel: mocks.createMcpSetupPanel,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
describe("commands onboarding", () => {
|
|
25
|
+
const originalHome = process.env.HOME;
|
|
26
|
+
const originalOAuthDir = process.env.MCP_OAUTH_DIR;
|
|
27
|
+
const originalCwd = process.cwd();
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.resetModules();
|
|
31
|
+
mocks.createMcpPanel.mockReset().mockImplementation((_config, _cache, _prov, _callbacks, _tui, done) => {
|
|
32
|
+
done({ cancelled: true, changes: new Map() });
|
|
33
|
+
return { dispose() {} };
|
|
34
|
+
});
|
|
35
|
+
mocks.createMcpSetupPanel.mockReset().mockImplementation((_discovery, _callbacks, _options, _tui, done) => {
|
|
36
|
+
done();
|
|
37
|
+
return { dispose() {} };
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
process.env.HOME = originalHome;
|
|
43
|
+
if (originalOAuthDir === undefined) {
|
|
44
|
+
delete process.env.MCP_OAUTH_DIR;
|
|
45
|
+
} else {
|
|
46
|
+
process.env.MCP_OAUTH_DIR = originalOAuthDir;
|
|
47
|
+
}
|
|
48
|
+
process.chdir(originalCwd);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function createUi() {
|
|
52
|
+
return {
|
|
53
|
+
notify: vi.fn(),
|
|
54
|
+
setStatus: vi.fn(),
|
|
55
|
+
custom: vi.fn((renderer: any) => renderer({ requestRender: vi.fn() }, {}, {}, vi.fn())),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
it("opens setup mode when no MCP servers are configured", async () => {
|
|
60
|
+
process.env.HOME = mkdtempSync(join(tmpdir(), "pi-mcp-commands-home-"));
|
|
61
|
+
const ui = createUi();
|
|
62
|
+
const { openMcpPanel } = await import("../commands.ts");
|
|
63
|
+
|
|
64
|
+
await openMcpPanel({
|
|
65
|
+
config: { mcpServers: {} },
|
|
66
|
+
manager: { getConnection: () => null },
|
|
67
|
+
toolMetadata: new Map(),
|
|
68
|
+
failureTracker: new Map(),
|
|
69
|
+
} as any, { getFlag: () => undefined } as any, { hasUI: true, ui } as any);
|
|
70
|
+
|
|
71
|
+
expect(mocks.createMcpSetupPanel).toHaveBeenCalled();
|
|
72
|
+
expect(mocks.createMcpPanel).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("shows a one-time shared-config notice in the MCP panel", async () => {
|
|
76
|
+
const home = mkdtempSync(join(tmpdir(), "pi-mcp-commands-home-"));
|
|
77
|
+
const project = mkdtempSync(join(tmpdir(), "pi-mcp-commands-project-"));
|
|
78
|
+
process.env.HOME = home;
|
|
79
|
+
process.chdir(project);
|
|
80
|
+
|
|
81
|
+
writeJson(join(home, ".config", "mcp", "mcp.json"), {
|
|
82
|
+
mcpServers: {
|
|
83
|
+
sharedServer: { command: "shared" },
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const ui = createUi();
|
|
88
|
+
const { loadMcpConfig } = await import("../config.ts");
|
|
89
|
+
const { openMcpPanel } = await import("../commands.ts");
|
|
90
|
+
const { loadOnboardingState } = await import("../onboarding-state.ts");
|
|
91
|
+
|
|
92
|
+
await openMcpPanel({
|
|
93
|
+
config: loadMcpConfig(),
|
|
94
|
+
manager: { getConnection: () => null },
|
|
95
|
+
toolMetadata: new Map(),
|
|
96
|
+
failureTracker: new Map(),
|
|
97
|
+
} as any, { getFlag: () => undefined } as any, { hasUI: true, ui } as any);
|
|
98
|
+
|
|
99
|
+
expect(mocks.createMcpPanel).toHaveBeenCalled();
|
|
100
|
+
const options = mocks.createMcpPanel.mock.calls[0]?.[6];
|
|
101
|
+
expect(options.noticeLines[0]).toContain("Using standard MCP config");
|
|
102
|
+
expect(loadOnboardingState().sharedConfigHintShown).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("clears OAuth credentials, cancels pending auth, and closes the server on logout", async () => {
|
|
106
|
+
process.env.MCP_OAUTH_DIR = mkdtempSync(join(tmpdir(), "pi-mcp-commands-logout-"));
|
|
107
|
+
const ui = createUi();
|
|
108
|
+
const close = vi.fn();
|
|
109
|
+
const { getAuthEntry, updateOAuthState, updateTokens } = await import("../mcp-auth.ts");
|
|
110
|
+
const { waitForCallback } = await import("../mcp-callback-server.ts");
|
|
111
|
+
const { logoutServer } = await import("../commands.ts");
|
|
112
|
+
|
|
113
|
+
updateTokens("oauth-server", { accessToken: "token", refreshToken: "refresh" }, "https://example.com/mcp");
|
|
114
|
+
updateOAuthState("oauth-server", "pending-state", "https://example.com/mcp");
|
|
115
|
+
const pendingCallback = waitForCallback("pending-state");
|
|
116
|
+
const pendingCallbackRejection = expect(pendingCallback).rejects.toThrow("Authorization cancelled");
|
|
117
|
+
|
|
118
|
+
const result = await logoutServer("oauth-server", {
|
|
119
|
+
config: { mcpServers: { "oauth-server": { url: "https://example.com/mcp", auth: "oauth" } } },
|
|
120
|
+
manager: { close },
|
|
121
|
+
toolMetadata: new Map(),
|
|
122
|
+
failureTracker: new Map(),
|
|
123
|
+
} as any, { hasUI: true, ui } as any);
|
|
124
|
+
|
|
125
|
+
await pendingCallbackRejection;
|
|
126
|
+
expect(result.ok).toBe(true);
|
|
127
|
+
expect(getAuthEntry("oauth-server")).toBeUndefined();
|
|
128
|
+
expect(close).toHaveBeenCalledWith("oauth-server");
|
|
129
|
+
expect(ui.notify).toHaveBeenCalledWith(expect.stringContaining("OAuth credentials cleared"), "info");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("marks explicit OAuth servers as needs-auth when only stale URL tokens exist", async () => {
|
|
133
|
+
process.env.MCP_OAUTH_DIR = mkdtempSync(join(tmpdir(), "pi-mcp-commands-oauth-"));
|
|
134
|
+
const ui = createUi();
|
|
135
|
+
const { updateTokens } = await import("../mcp-auth.ts");
|
|
136
|
+
const { openMcpPanel } = await import("../commands.ts");
|
|
137
|
+
|
|
138
|
+
updateTokens("legacy", { accessToken: "legacy-token" });
|
|
139
|
+
updateTokens("stale", { accessToken: "stale-token" }, "https://old.example.com/mcp");
|
|
140
|
+
|
|
141
|
+
await openMcpPanel({
|
|
142
|
+
config: {
|
|
143
|
+
mcpServers: {
|
|
144
|
+
legacy: { url: "https://new.example.com/mcp", auth: "oauth" },
|
|
145
|
+
stale: { url: "https://new.example.com/mcp", auth: "oauth" },
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
manager: { getConnection: () => null },
|
|
149
|
+
toolMetadata: new Map(),
|
|
150
|
+
failureTracker: new Map(),
|
|
151
|
+
} as any, { getFlag: () => undefined } as any, { hasUI: true, ui } as any);
|
|
152
|
+
|
|
153
|
+
const callbacks = mocks.createMcpPanel.mock.calls[0]?.[3];
|
|
154
|
+
expect(callbacks.getConnectionStatus("legacy")).toBe("needs-auth");
|
|
155
|
+
expect(callbacks.getConnectionStatus("stale")).toBe("needs-auth");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
function writeJson(path: string, value: unknown): void {
|
|
7
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
8
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("config discovery", () => {
|
|
12
|
+
const originalHome = process.env.HOME;
|
|
13
|
+
const originalCwd = process.cwd();
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
process.env.HOME = originalHome;
|
|
21
|
+
process.chdir(originalCwd);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("loads standard MCP files first, then Pi overrides", async () => {
|
|
25
|
+
const home = mkdtempSync(join(tmpdir(), "pi-mcp-config-home-"));
|
|
26
|
+
const project = mkdtempSync(join(tmpdir(), "pi-mcp-config-project-"));
|
|
27
|
+
process.env.HOME = home;
|
|
28
|
+
process.chdir(project);
|
|
29
|
+
const realProject = realpathSync(project);
|
|
30
|
+
|
|
31
|
+
writeJson(join(home, ".config", "mcp", "mcp.json"), {
|
|
32
|
+
settings: { idleTimeout: 5 },
|
|
33
|
+
mcpServers: {
|
|
34
|
+
shared: { command: "generic" },
|
|
35
|
+
genericOnly: { command: "generic-only" },
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
writeJson(join(home, ".pi", "agent", "mcp.json"), {
|
|
40
|
+
settings: { toolPrefix: "short", directTools: true },
|
|
41
|
+
mcpServers: {
|
|
42
|
+
shared: { command: "pi-global" },
|
|
43
|
+
piOnly: { command: "pi-only" },
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
writeJson(join(project, ".mcp.json"), {
|
|
48
|
+
settings: { toolPrefix: "none" },
|
|
49
|
+
mcpServers: {
|
|
50
|
+
shared: { command: "project" },
|
|
51
|
+
projectOnly: { command: "project-only" },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
writeJson(join(project, ".pi", "mcp.json"), {
|
|
56
|
+
settings: { autoAuth: true },
|
|
57
|
+
mcpServers: {
|
|
58
|
+
shared: { command: "project-pi" },
|
|
59
|
+
projectPiOnly: { command: "project-pi-only" },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const { loadMcpConfig } = await import("../config.ts");
|
|
64
|
+
const config = loadMcpConfig();
|
|
65
|
+
|
|
66
|
+
expect(config.mcpServers.shared).toMatchObject({ command: "project-pi" });
|
|
67
|
+
expect(config.mcpServers.genericOnly).toMatchObject({ command: "generic-only" });
|
|
68
|
+
expect(config.mcpServers.piOnly).toMatchObject({ command: "pi-only" });
|
|
69
|
+
expect(config.mcpServers.projectOnly).toMatchObject({ command: "project-only" });
|
|
70
|
+
expect(config.mcpServers.projectPiOnly).toMatchObject({ command: "project-pi-only" });
|
|
71
|
+
expect(config.settings).toEqual({
|
|
72
|
+
idleTimeout: 5,
|
|
73
|
+
toolPrefix: "none",
|
|
74
|
+
directTools: true,
|
|
75
|
+
autoAuth: true,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("prefers modern Claude Code config detection over legacy paths", async () => {
|
|
80
|
+
const home = mkdtempSync(join(tmpdir(), "pi-mcp-import-home-"));
|
|
81
|
+
const project = mkdtempSync(join(tmpdir(), "pi-mcp-import-project-"));
|
|
82
|
+
process.env.HOME = home;
|
|
83
|
+
process.chdir(project);
|
|
84
|
+
const realProject = realpathSync(project);
|
|
85
|
+
|
|
86
|
+
writeJson(join(home, ".claude", "mcp.json"), { mcpServers: { modern: { command: "modern" } } });
|
|
87
|
+
writeJson(join(home, ".claude.json"), { mcpServers: { old: { command: "old" } } });
|
|
88
|
+
writeJson(join(project, ".vscode", "mcp.json"), { mcpServers: { editor: { command: "code" } } });
|
|
89
|
+
|
|
90
|
+
const { findAvailableImportConfigs } = await import("../config.ts");
|
|
91
|
+
const imports = findAvailableImportConfigs();
|
|
92
|
+
|
|
93
|
+
expect(imports).toEqual(
|
|
94
|
+
expect.arrayContaining([
|
|
95
|
+
{ kind: "claude-code", path: join(home, ".claude", "mcp.json") },
|
|
96
|
+
{ kind: "vscode", path: resolve(realProject, ".vscode", "mcp.json") },
|
|
97
|
+
]),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("tracks provenance so project servers write locally and shared/imported servers write to Pi config", async () => {
|
|
102
|
+
const home = mkdtempSync(join(tmpdir(), "pi-mcp-provenance-home-"));
|
|
103
|
+
const project = mkdtempSync(join(tmpdir(), "pi-mcp-provenance-project-"));
|
|
104
|
+
process.env.HOME = home;
|
|
105
|
+
process.chdir(project);
|
|
106
|
+
const realProject = realpathSync(project);
|
|
107
|
+
|
|
108
|
+
writeJson(join(home, ".config", "mcp", "mcp.json"), {
|
|
109
|
+
mcpServers: {
|
|
110
|
+
genericServer: { command: "generic" },
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
writeJson(join(home, ".pi", "agent", "mcp.json"), {
|
|
115
|
+
imports: ["cursor"],
|
|
116
|
+
mcpServers: {
|
|
117
|
+
userServer: { command: "user" },
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
writeJson(join(home, ".cursor", "mcp.json"), {
|
|
122
|
+
mcpServers: {
|
|
123
|
+
importedServer: { command: "cursor" },
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
writeJson(join(project, ".mcp.json"), {
|
|
128
|
+
mcpServers: {
|
|
129
|
+
projectServer: { command: "project" },
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
writeJson(join(project, ".pi", "mcp.json"), {
|
|
134
|
+
mcpServers: {
|
|
135
|
+
projectPiServer: { command: "project-pi" },
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const { getServerProvenance, getPiGlobalConfigPath } = await import("../config.ts");
|
|
140
|
+
const provenance = getServerProvenance();
|
|
141
|
+
const piConfigPath = getPiGlobalConfigPath();
|
|
142
|
+
|
|
143
|
+
expect(provenance.get("genericServer")).toEqual({
|
|
144
|
+
path: piConfigPath,
|
|
145
|
+
kind: "import",
|
|
146
|
+
importKind: "global MCP config",
|
|
147
|
+
});
|
|
148
|
+
expect(provenance.get("importedServer")).toEqual({
|
|
149
|
+
path: piConfigPath,
|
|
150
|
+
kind: "import",
|
|
151
|
+
importKind: "cursor",
|
|
152
|
+
});
|
|
153
|
+
expect(provenance.get("userServer")).toEqual({
|
|
154
|
+
path: piConfigPath,
|
|
155
|
+
kind: "user",
|
|
156
|
+
importKind: undefined,
|
|
157
|
+
});
|
|
158
|
+
expect(provenance.get("projectServer")).toEqual({
|
|
159
|
+
path: resolve(realProject, ".mcp.json"),
|
|
160
|
+
kind: "project",
|
|
161
|
+
importKind: undefined,
|
|
162
|
+
});
|
|
163
|
+
expect(provenance.get("projectPiServer")).toEqual({
|
|
164
|
+
path: resolve(realProject, ".pi", "mcp.json"),
|
|
165
|
+
kind: "project",
|
|
166
|
+
importKind: undefined,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("summarizes discovery and detects RepoPrompt suggestions", async () => {
|
|
171
|
+
const home = mkdtempSync(join(tmpdir(), "pi-mcp-summary-home-"));
|
|
172
|
+
const project = mkdtempSync(join(tmpdir(), "pi-mcp-summary-project-"));
|
|
173
|
+
process.env.HOME = home;
|
|
174
|
+
process.chdir(project);
|
|
175
|
+
const realProject = realpathSync(project);
|
|
176
|
+
|
|
177
|
+
writeJson(join(home, ".config", "mcp", "mcp.json"), {
|
|
178
|
+
mcpServers: {
|
|
179
|
+
sharedServer: { command: "shared" },
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
writeJson(join(project, "package.json"), { name: "fixture" });
|
|
184
|
+
writeJson(join(home, "RepoPrompt", "repoprompt_cli"), "#!/bin/sh\n");
|
|
185
|
+
writeJson(join(home, ".cursor", "mcp.json"), {
|
|
186
|
+
mcpServers: {
|
|
187
|
+
importedServer: { command: "cursor" },
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const { getMcpDiscoverySummary } = await import("../config.ts");
|
|
192
|
+
const summary = getMcpDiscoverySummary();
|
|
193
|
+
|
|
194
|
+
expect(summary.hasSharedServers).toBe(true);
|
|
195
|
+
expect(summary.sources.find((source) => source.id === "shared-global")?.serverCount).toBe(1);
|
|
196
|
+
expect(summary.imports).toEqual(
|
|
197
|
+
expect.arrayContaining([
|
|
198
|
+
expect.objectContaining({ kind: "cursor", serverCount: 1 }),
|
|
199
|
+
]),
|
|
200
|
+
);
|
|
201
|
+
expect(summary.repoPrompt).toMatchObject({
|
|
202
|
+
configured: false,
|
|
203
|
+
executablePath: join(home, "RepoPrompt", "repoprompt_cli"),
|
|
204
|
+
targetPath: resolve(realProject, ".mcp.json"),
|
|
205
|
+
serverName: "repoprompt",
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("writes imported/global changes to Pi config and project changes to the project file", async () => {
|
|
210
|
+
const home = mkdtempSync(join(tmpdir(), "pi-mcp-write-home-"));
|
|
211
|
+
const project = mkdtempSync(join(tmpdir(), "pi-mcp-write-project-"));
|
|
212
|
+
process.env.HOME = home;
|
|
213
|
+
process.chdir(project);
|
|
214
|
+
|
|
215
|
+
writeJson(join(home, ".config", "mcp", "mcp.json"), {
|
|
216
|
+
mcpServers: {
|
|
217
|
+
genericServer: { command: "generic" },
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
writeJson(join(home, ".pi", "agent", "mcp.json"), {
|
|
222
|
+
mcpServers: {},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
writeJson(join(project, ".mcp.json"), {
|
|
226
|
+
mcpServers: {
|
|
227
|
+
projectServer: { command: "project" },
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const { getServerProvenance, loadMcpConfig, writeDirectToolsConfig, getPiGlobalConfigPath } = await import("../config.ts");
|
|
232
|
+
const fullConfig = loadMcpConfig();
|
|
233
|
+
const provenance = getServerProvenance();
|
|
234
|
+
|
|
235
|
+
writeDirectToolsConfig(
|
|
236
|
+
new Map([
|
|
237
|
+
["genericServer", true],
|
|
238
|
+
["projectServer", ["search"]],
|
|
239
|
+
]),
|
|
240
|
+
provenance,
|
|
241
|
+
fullConfig,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const userConfig = JSON.parse(readFileSync(getPiGlobalConfigPath(), "utf-8"));
|
|
245
|
+
expect(userConfig.mcpServers.genericServer).toMatchObject({ command: "generic", directTools: true });
|
|
246
|
+
|
|
247
|
+
const projectConfig = JSON.parse(readFileSync(join(project, ".mcp.json"), "utf-8"));
|
|
248
|
+
expect(projectConfig.mcpServers.projectServer).toMatchObject({ command: "project", directTools: ["search"] });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("builds real diff previews for compatibility imports and shared server writes", async () => {
|
|
252
|
+
const home = mkdtempSync(join(tmpdir(), "pi-mcp-preview-home-"));
|
|
253
|
+
const project = mkdtempSync(join(tmpdir(), "pi-mcp-preview-project-"));
|
|
254
|
+
process.env.HOME = home;
|
|
255
|
+
process.chdir(project);
|
|
256
|
+
|
|
257
|
+
writeJson(join(home, ".pi", "agent", "mcp.json"), {
|
|
258
|
+
imports: ["cursor"],
|
|
259
|
+
mcpServers: {
|
|
260
|
+
existing: { command: "demo" },
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const {
|
|
265
|
+
previewCompatibilityImports,
|
|
266
|
+
previewSharedServerEntry,
|
|
267
|
+
getGenericGlobalConfigPath,
|
|
268
|
+
} = await import("../config.ts");
|
|
269
|
+
|
|
270
|
+
const importsPreview = previewCompatibilityImports(["cursor", "codex"]);
|
|
271
|
+
expect(importsPreview.path).toContain(".pi/agent/mcp.json");
|
|
272
|
+
expect(importsPreview.changed).toBe(true);
|
|
273
|
+
expect(importsPreview.diffText).toContain("+++ after");
|
|
274
|
+
expect(importsPreview.diffText).toContain('+ "codex"');
|
|
275
|
+
|
|
276
|
+
const sharedPreview = previewSharedServerEntry(getGenericGlobalConfigPath(), "repoprompt", {
|
|
277
|
+
command: "/tmp/repoprompt_cli",
|
|
278
|
+
args: [],
|
|
279
|
+
lifecycle: "lazy",
|
|
280
|
+
});
|
|
281
|
+
expect(sharedPreview.existed).toBe(false);
|
|
282
|
+
expect(sharedPreview.diffText).toContain('+ "mcpServers": {');
|
|
283
|
+
expect(sharedPreview.diffText).toContain('+ "repoprompt": {');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("writes selected compatibility imports and a starter project config", async () => {
|
|
287
|
+
const home = mkdtempSync(join(tmpdir(), "pi-mcp-setup-home-"));
|
|
288
|
+
const project = mkdtempSync(join(tmpdir(), "pi-mcp-setup-project-"));
|
|
289
|
+
process.env.HOME = home;
|
|
290
|
+
process.chdir(project);
|
|
291
|
+
|
|
292
|
+
const { ensureCompatibilityImports, getPiGlobalConfigPath, writeStarterProjectConfig } = await import("../config.ts");
|
|
293
|
+
const importResult = ensureCompatibilityImports(["cursor", "codex"]);
|
|
294
|
+
expect(importResult.added).toEqual(["cursor", "codex"]);
|
|
295
|
+
|
|
296
|
+
const piConfig = JSON.parse(readFileSync(getPiGlobalConfigPath(), "utf-8"));
|
|
297
|
+
expect(piConfig.imports).toEqual(["cursor", "codex"]);
|
|
298
|
+
|
|
299
|
+
const starterPath = writeStarterProjectConfig();
|
|
300
|
+
const starter = JSON.parse(readFileSync(starterPath, "utf-8"));
|
|
301
|
+
expect(starter.mcpServers).toEqual({});
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { ConsentManager, type ToolConsentMode } from "../consent-manager.ts";
|
|
3
|
+
|
|
4
|
+
describe("ConsentManager", () => {
|
|
5
|
+
describe("mode: never", () => {
|
|
6
|
+
let manager: ConsentManager;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
manager = new ConsentManager("never");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("never requires prompt", () => {
|
|
13
|
+
expect(manager.requiresPrompt("any-server")).toBe(false);
|
|
14
|
+
expect(manager.requiresPrompt("another-server")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("should not cache consent", () => {
|
|
18
|
+
// "never" mode means no consent needed, so caching is irrelevant
|
|
19
|
+
// but the API returns true for consistency
|
|
20
|
+
expect(manager.shouldCacheConsent()).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("ensureApproved never throws", () => {
|
|
24
|
+
expect(() => manager.ensureApproved("any-server")).not.toThrow();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("registering denial has no effect", () => {
|
|
28
|
+
manager.registerDecision("test-server", false);
|
|
29
|
+
expect(manager.requiresPrompt("test-server")).toBe(false);
|
|
30
|
+
expect(() => manager.ensureApproved("test-server")).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("mode: once-per-server (default)", () => {
|
|
35
|
+
let manager: ConsentManager;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
manager = new ConsentManager("once-per-server");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("requires prompt for new servers", () => {
|
|
42
|
+
expect(manager.requiresPrompt("server-a")).toBe(true);
|
|
43
|
+
expect(manager.requiresPrompt("server-b")).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not require prompt after approval", () => {
|
|
47
|
+
manager.registerDecision("server-a", true);
|
|
48
|
+
expect(manager.requiresPrompt("server-a")).toBe(false);
|
|
49
|
+
// Other servers still require prompt
|
|
50
|
+
expect(manager.requiresPrompt("server-b")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("requires prompt after denial", () => {
|
|
54
|
+
manager.registerDecision("server-a", false);
|
|
55
|
+
expect(manager.requiresPrompt("server-a")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should cache consent", () => {
|
|
59
|
+
expect(manager.shouldCacheConsent()).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("ensureApproved throws for unapproved server", () => {
|
|
63
|
+
expect(() => manager.ensureApproved("server-a")).toThrow(
|
|
64
|
+
'Tool call approval required for "server-a"'
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("ensureApproved throws for denied server", () => {
|
|
69
|
+
manager.registerDecision("server-a", false);
|
|
70
|
+
expect(() => manager.ensureApproved("server-a")).toThrow(
|
|
71
|
+
'Tool calls for "server-a" were denied for this session'
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("ensureApproved succeeds after approval", () => {
|
|
76
|
+
manager.registerDecision("server-a", true);
|
|
77
|
+
expect(() => manager.ensureApproved("server-a")).not.toThrow();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("approval overrides previous denial", () => {
|
|
81
|
+
manager.registerDecision("server-a", false);
|
|
82
|
+
manager.registerDecision("server-a", true);
|
|
83
|
+
expect(manager.requiresPrompt("server-a")).toBe(false);
|
|
84
|
+
expect(() => manager.ensureApproved("server-a")).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("denial overrides previous approval", () => {
|
|
88
|
+
manager.registerDecision("server-a", true);
|
|
89
|
+
manager.registerDecision("server-a", false);
|
|
90
|
+
expect(manager.requiresPrompt("server-a")).toBe(true);
|
|
91
|
+
expect(() => manager.ensureApproved("server-a")).toThrow();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("mode: always", () => {
|
|
96
|
+
let manager: ConsentManager;
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
manager = new ConsentManager("always");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("always requires prompt", () => {
|
|
103
|
+
expect(manager.requiresPrompt("server-a")).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("still requires prompt after approval", () => {
|
|
107
|
+
manager.registerDecision("server-a", true);
|
|
108
|
+
expect(manager.requiresPrompt("server-a")).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should not cache consent", () => {
|
|
112
|
+
expect(manager.shouldCacheConsent()).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("ensureApproved consumes the approval", () => {
|
|
116
|
+
manager.registerDecision("server-a", true);
|
|
117
|
+
// First call succeeds
|
|
118
|
+
expect(() => manager.ensureApproved("server-a")).not.toThrow();
|
|
119
|
+
// Second call fails (approval was consumed)
|
|
120
|
+
expect(() => manager.ensureApproved("server-a")).toThrow(
|
|
121
|
+
'Tool call approval required for "server-a"'
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("clear", () => {
|
|
127
|
+
let manager: ConsentManager;
|
|
128
|
+
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
manager = new ConsentManager("once-per-server");
|
|
131
|
+
manager.registerDecision("server-a", true);
|
|
132
|
+
manager.registerDecision("server-b", false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("clears specific server", () => {
|
|
136
|
+
manager.clear("server-a");
|
|
137
|
+
expect(manager.requiresPrompt("server-a")).toBe(true);
|
|
138
|
+
// server-b unchanged
|
|
139
|
+
expect(() => manager.ensureApproved("server-b")).toThrow(/denied/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("clears all servers", () => {
|
|
143
|
+
manager.clear();
|
|
144
|
+
expect(manager.requiresPrompt("server-a")).toBe(true);
|
|
145
|
+
expect(manager.requiresPrompt("server-b")).toBe(true);
|
|
146
|
+
// Neither approved nor denied
|
|
147
|
+
expect(() => manager.ensureApproved("server-a")).toThrow(/approval required/);
|
|
148
|
+
expect(() => manager.ensureApproved("server-b")).toThrow(/approval required/);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|