decorated-pi 0.3.0 → 0.4.1

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.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * LSP type definitions — minimal set needed by this extension.
3
+ */
4
+
5
+ export interface LspPosition {
6
+ line: number;
7
+ character: number;
8
+ }
9
+
10
+ export interface LspRange {
11
+ start: LspPosition;
12
+ end: LspPosition;
13
+ }
14
+
15
+ export interface LspLocation {
16
+ uri: string;
17
+ range: LspRange;
18
+ }
19
+
20
+ export interface LspDiagnostic {
21
+ range: LspRange;
22
+ severity?: number;
23
+ code?: unknown;
24
+ source?: string;
25
+ message: string;
26
+ }
27
+
28
+ export interface LspHover {
29
+ contents: unknown;
30
+ range?: LspRange;
31
+ }
32
+
33
+ export interface LspDocumentSymbol {
34
+ name: string;
35
+ kind: number;
36
+ range: LspRange;
37
+ selectionRange?: LspRange;
38
+ containerName?: string;
39
+ detail?: string;
40
+ children?: LspDocumentSymbol[];
41
+ uri?: string;
42
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * MCP server configuration — builtin + global + project-level.
3
+ */
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import { loadConfig } from "../settings.js";
7
+
8
+ export interface McpServerConfig {
9
+ name: string;
10
+ url: string;
11
+ description?: string;
12
+ enabled: boolean;
13
+ source: "builtin" | "global" | "project";
14
+ }
15
+
16
+ /** Builtin servers — zero-config, always available unless overridden. */
17
+ export const BUILTIN_MCP_SERVERS: Omit<McpServerConfig, "source">[] = [
18
+ {
19
+ name: "context7",
20
+ url: "https://mcp.context7.com/mcp",
21
+ description: "Context7 documentation and code examples",
22
+ enabled: true,
23
+ },
24
+ {
25
+ name: "exa",
26
+ url: "https://mcp.exa.ai/mcp",
27
+ description: "Exa web search",
28
+ enabled: true,
29
+ },
30
+ ];
31
+
32
+ // ── Project-level config discovery ─────────────────────────────────────────
33
+
34
+ const PROJECT_CONFIG_PATHS = [
35
+ ".pi/mcp.json",
36
+ ".pi/.mcp.json",
37
+ ".agents/mcp.json",
38
+ ".agents/.mcp.json",
39
+ ".claude/mcp.json",
40
+ ".claude/.mcp.json",
41
+ "mcp.json",
42
+ ".mcp.json",
43
+ ];
44
+
45
+ function readMcpJson(filePath: string): Record<string, { url: string; enabled?: boolean }> | null {
46
+ try {
47
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
48
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
49
+ const servers = raw.mcpServers ?? raw["mcp-servers"];
50
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) return null;
51
+ return servers as Record<string, { url: string; enabled?: boolean }>;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /** Load project-level MCP configs from cwd and its ancestor directories. */
58
+ export function loadProjectMcpConfigs(cwd: string): McpServerConfig[] {
59
+ const configs: McpServerConfig[] = [];
60
+ const seen = new Set<string>();
61
+
62
+ let current = path.resolve(cwd);
63
+ while (true) {
64
+ for (const relative of PROJECT_CONFIG_PATHS) {
65
+ const filePath = path.join(current, relative);
66
+ if (!fs.existsSync(filePath)) continue;
67
+ const servers = readMcpJson(filePath);
68
+ if (!servers) continue;
69
+
70
+ for (const [name, entry] of Object.entries(servers)) {
71
+ if (seen.has(name)) continue;
72
+ seen.add(name);
73
+ configs.push({
74
+ name,
75
+ url: entry.url,
76
+ enabled: entry.enabled !== false,
77
+ source: "project",
78
+ });
79
+ }
80
+ }
81
+
82
+ const parent = path.dirname(current);
83
+ if (parent === current) break;
84
+ current = parent;
85
+ }
86
+
87
+ return configs;
88
+ }
89
+
90
+ /** Load global MCP configs from ~/.pi/agent/decorated-pi.json. */
91
+ export function loadGlobalMcpConfigs(): McpServerConfig[] {
92
+ const config = loadConfig();
93
+ if (!config.mcpServers) return [];
94
+
95
+ return Object.entries(config.mcpServers).map(([name, entry]) => ({
96
+ name,
97
+ url: entry.url,
98
+ enabled: entry.enabled !== false,
99
+ source: "global" as const,
100
+ }));
101
+ }
102
+
103
+ /**
104
+ * Merge all MCP configs: builtin → global → project.
105
+ * Later sources override earlier ones for the same server name.
106
+ */
107
+ export function resolveMcpConfigs(cwd: string): McpServerConfig[] {
108
+ const byName = new Map<string, McpServerConfig>();
109
+
110
+ // Builtin (lowest priority)
111
+ for (const s of BUILTIN_MCP_SERVERS) {
112
+ byName.set(s.name, { ...s, source: "builtin" });
113
+ }
114
+
115
+ // Global
116
+ for (const s of loadGlobalMcpConfigs()) {
117
+ byName.set(s.name, s);
118
+ }
119
+
120
+ // Project (highest priority)
121
+ for (const s of loadProjectMcpConfigs(cwd)) {
122
+ byName.set(s.name, s);
123
+ }
124
+
125
+ return [...byName.values()].filter((s) => s.enabled);
126
+ }
@@ -0,0 +1,106 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
+
5
+ export interface McpToolSpec {
6
+ name: string;
7
+ description: string;
8
+ inputSchema: Record<string, unknown>;
9
+ }
10
+
11
+ /** Per-server MCP client wrapper with fallback transport. */
12
+ export class McpConnection {
13
+ client: Client;
14
+ transport: StreamableHTTPClientTransport | SSEClientTransport | undefined;
15
+ tools: McpToolSpec[] = [];
16
+ private connected = false;
17
+
18
+ source: string = "unknown";
19
+
20
+ constructor(
21
+ public readonly serverName: string,
22
+ public readonly url: string,
23
+ ) {
24
+ this.client = new Client({
25
+ name: `decorated-pi-${serverName}`,
26
+ version: "0.3.0",
27
+ });
28
+ }
29
+
30
+ async connect(timeoutMs = 8000): Promise<void> {
31
+ const connectWithFallback = async (): Promise<void> => {
32
+ let lastErr: Error | undefined;
33
+ try {
34
+ const transport = new StreamableHTTPClientTransport(new URL(this.url));
35
+ await this.client.connect(transport);
36
+ this.transport = transport;
37
+ this.connected = true;
38
+ } catch (err) {
39
+ lastErr = err instanceof Error ? err : new Error(String(err));
40
+ try {
41
+ const transport = new SSEClientTransport(new URL(this.url));
42
+ await this.client.connect(transport);
43
+ this.transport = transport;
44
+ this.connected = true;
45
+ } catch (sseErr) {
46
+ const sseMessage = sseErr instanceof Error ? sseErr.message : String(sseErr);
47
+ throw new Error(
48
+ `MCP ${this.serverName}: StreamableHTTP failed (${lastErr.message}); SSE fallback also failed (${sseMessage})`,
49
+ );
50
+ }
51
+ }
52
+
53
+ const result = (await this.client.listTools()) as unknown as {
54
+ tools: Array<{
55
+ name: string;
56
+ description?: string;
57
+ inputSchema?: Record<string, unknown>;
58
+ }>;
59
+ };
60
+
61
+ this.tools = (result.tools ?? []).map((t) => ({
62
+ name: t.name,
63
+ description: t.description || "",
64
+ inputSchema: t.inputSchema || { type: "object", properties: {} },
65
+ }));
66
+ };
67
+
68
+ const timeout = new Promise<never>((_, reject) =>
69
+ setTimeout(() => reject(new Error(`MCP ${this.serverName}: connection timed out after ${timeoutMs}ms`)), timeoutMs),
70
+ );
71
+
72
+ await Promise.race([connectWithFallback(), timeout]);
73
+ }
74
+
75
+ async callTool(name: string, args: Record<string, unknown>): Promise<string> {
76
+ if (!this.connected) {
77
+ throw new Error(`MCP ${this.serverName}: not connected`);
78
+ }
79
+ const result = (await this.client.callTool({
80
+ name,
81
+ arguments: args,
82
+ })) as unknown as {
83
+ content?: Array<{ type: string; text?: string }>;
84
+ isError?: boolean;
85
+ };
86
+
87
+ const text = (result.content ?? [])
88
+ .filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string")
89
+ .map((c) => c.text)
90
+ .join("\n");
91
+
92
+ if (result.isError) {
93
+ throw new Error(text || `MCP tool "${name}" returned an error`);
94
+ }
95
+
96
+ return text || "(empty result)";
97
+ }
98
+
99
+ async disconnect(): Promise<void> {
100
+ if (!this.connected) return;
101
+ this.connected = false;
102
+ try {
103
+ await this.client.close();
104
+ } catch {}
105
+ }
106
+ }
@@ -0,0 +1,123 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { McpConnection } from "./client.js";
4
+ import { resolveMcpConfigs } from "./builtin.js";
5
+
6
+ export interface McpServerStatus {
7
+ name: string;
8
+ url: string;
9
+ source: string;
10
+ state: "connecting" | "connected" | "failed";
11
+ toolCount: number;
12
+ tools: Array<{ name: string; description: string }>;
13
+ error?: string;
14
+ }
15
+
16
+ let activeConnections: McpConnection[] = [];
17
+ let allServers = new Map<string, McpServerStatus>();
18
+ let connectPromise: Promise<void> | null = null;
19
+
20
+ export function setupMcp(pi: ExtensionAPI) {
21
+ pi.on("session_start", (_event, ctx: ExtensionContext) => {
22
+ void (async () => {
23
+ await teardownMcp();
24
+
25
+ const configs = resolveMcpConfigs(ctx.cwd);
26
+ if (configs.length === 0) return;
27
+
28
+ // Initialise every target server as "connecting"
29
+ allServers = new Map(
30
+ configs.map((s) => [
31
+ s.name,
32
+ {
33
+ name: s.name,
34
+ url: s.url,
35
+ source: s.source,
36
+ state: "connecting" as const,
37
+ toolCount: 0,
38
+ tools: [],
39
+ },
40
+ ]),
41
+ );
42
+
43
+ connectPromise = Promise.all(
44
+ configs.map(async (server) => {
45
+ const conn = new McpConnection(server.name, server.url);
46
+ conn.source = server.source;
47
+
48
+ try {
49
+ await conn.connect();
50
+ activeConnections.push(conn);
51
+
52
+ for (const tool of conn.tools) {
53
+ const prefixedName = `${server.name}_${tool.name}`;
54
+ pi.registerTool({
55
+ name: prefixedName,
56
+ label: `MCP: ${tool.name}`,
57
+ description: tool.description,
58
+ promptSnippet: tool.description.slice(0, 120),
59
+ parameters: Type.Unsafe(tool.inputSchema as never),
60
+ execute: async (_toolCallId, params, _signal, _onUpdate, _ctx2) => {
61
+ const text = await conn.callTool(
62
+ tool.name,
63
+ params as Record<string, unknown>,
64
+ );
65
+ return {
66
+ content: [{ type: "text" as const, text }],
67
+ isError: false,
68
+ details: { server: server.name, tool: tool.name },
69
+ };
70
+ },
71
+ });
72
+ }
73
+
74
+ allServers.set(server.name, {
75
+ name: server.name,
76
+ url: server.url,
77
+ source: server.source,
78
+ state: "connected",
79
+ toolCount: conn.tools.length,
80
+ tools: conn.tools.map((t) => ({ name: t.name, description: t.description })),
81
+ });
82
+ } catch (err) {
83
+ const msg = err instanceof Error ? err.message : String(err);
84
+ allServers.set(server.name, {
85
+ name: server.name,
86
+ url: server.url,
87
+ source: server.source,
88
+ state: "failed",
89
+ toolCount: 0,
90
+ tools: [],
91
+ error: msg,
92
+ });
93
+ }
94
+ }),
95
+ ).then(() => undefined);
96
+
97
+ await connectPromise;
98
+ connectPromise = null;
99
+ })();
100
+ });
101
+
102
+ pi.on("session_shutdown", () => {
103
+ void teardownMcp();
104
+ });
105
+ }
106
+
107
+ export function getMcpStatus(): McpServerStatus[] {
108
+ return [...allServers.values()];
109
+ }
110
+
111
+ async function teardownMcp(): Promise<void> {
112
+ await Promise.all(
113
+ activeConnections.map(async (conn) => {
114
+ try {
115
+ await conn.disconnect();
116
+ } catch {
117
+ // Silently ignore disconnect errors.
118
+ }
119
+ }),
120
+ );
121
+ activeConnections = [];
122
+ allServers = new Map();
123
+ }