mono-pilot 0.2.9 → 0.2.10
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/README.md +10 -5
- package/dist/src/extensions/session-hints.js +61 -35
- package/dist/src/extensions/user-message.js +24 -49
- package/dist/src/mcp/config.js +112 -0
- package/dist/src/mcp/protocol.js +164 -0
- package/dist/src/mcp/servers.js +90 -0
- package/dist/src/rules/discovery.js +41 -0
- package/dist/src/utils/mcp-client.js +32 -13
- package/dist/tools/README.md +1 -1
- package/dist/tools/call-mcp-tool.js +24 -104
- package/dist/tools/fetch-mcp-resource.js +28 -100
- package/dist/tools/list-mcp-resources.js +18 -58
- package/dist/tools/list-mcp-tools.js +18 -64
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,6 +48,8 @@ If you pass `--tools`, MonoPilot removes built-in `edit`, `write`, `read`, `grep
|
|
|
48
48
|
- `src/extensions/mono-pilot.ts` – extension entrypoint (tool wiring)
|
|
49
49
|
- `src/extensions/system-prompt.ts` – provider-agnostic prompt stack
|
|
50
50
|
- `src/extensions/user-message.ts` – user message envelope assembly
|
|
51
|
+
- `src/mcp/` – config loading, JSON-RPC transport, server resolution
|
|
52
|
+
- `src/rules/` – rule file discovery (shared by envelope and session hints)
|
|
51
53
|
- `tools/` – tool implementations and descriptions (see `tools/README.md`)
|
|
52
54
|
|
|
53
55
|
## Cursor-styled tools
|
|
@@ -85,17 +87,20 @@ The full Cursor-styled tool list exposed by the extension:
|
|
|
85
87
|
|
|
86
88
|
## User rules
|
|
87
89
|
|
|
88
|
-
MonoPilot
|
|
90
|
+
MonoPilot injects user rules into the runtime envelope on each turn (handled by `src/extensions/user-message.ts`).
|
|
89
91
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
-
|
|
93
|
-
-
|
|
92
|
+
Rules are loaded from two locations:
|
|
93
|
+
|
|
94
|
+
- `~/.pi/rules/*.rule.txt` – user-level rules (applies to all projects)
|
|
95
|
+
- `.pi/rules/*.rule.txt` – project-level rules (workspace root)
|
|
96
|
+
|
|
97
|
+
When the same filename exists in both, the project rule wins. Each file becomes one `<user_rule>` block inside a `<rules>` envelope, sorted by filename. Empty files are ignored; the envelope is omitted if no rules are found.
|
|
94
98
|
|
|
95
99
|
## MCP
|
|
96
100
|
|
|
97
101
|
- The user message envelope issues a lightweight MCP server `initialize` request to collect server instructions.
|
|
98
102
|
- MCP tools then progressively load and surface resources, schemas, and execution only when needed.
|
|
103
|
+
- MCP configs are loaded from `.pi/mcp.json` (project) and `~/.pi/mcp.json` (user); project entries take precedence on name conflicts.
|
|
99
104
|
|
|
100
105
|
## Local development
|
|
101
106
|
|
|
@@ -1,49 +1,42 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { readdir } from "node:fs/promises";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
|
-
import {
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
5
4
|
import { fileURLToPath } from "node:url";
|
|
6
5
|
import { Text } from "@mariozechner/pi-tui";
|
|
7
6
|
import { hasMessageEntries } from "./mode-runtime.js";
|
|
7
|
+
import { isServerEnabled, loadMcpConfig } from "../mcp/config.js";
|
|
8
|
+
import { toNonEmptyString } from "../mcp/config.js";
|
|
9
|
+
import { discoverRules } from "../rules/discovery.js";
|
|
8
10
|
const SESSION_HINTS_MESSAGE_TYPE = "SessionHints";
|
|
9
|
-
const RULES_RELATIVE_DIR = join(".pi", "rules");
|
|
10
11
|
const MONO_PILOT_NAME = "mono-pilot";
|
|
11
12
|
const MAX_VERSION_SEARCH_DEPTH = 6;
|
|
12
13
|
let cachedVersion = null;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (!existsSync(dirPath))
|
|
16
|
-
return [];
|
|
14
|
+
async function discoverMcpServers(cwd) {
|
|
15
|
+
let config;
|
|
17
16
|
try {
|
|
18
|
-
|
|
19
|
-
return entries
|
|
20
|
-
.filter((e) => e.isFile() && e.name.endsWith(".rule.txt"))
|
|
21
|
-
.map((e) => resolve(dirPath, e.name))
|
|
22
|
-
.sort((a, b) => a.localeCompare(b));
|
|
17
|
+
config = await loadMcpConfig(cwd);
|
|
23
18
|
}
|
|
24
19
|
catch {
|
|
25
|
-
return [];
|
|
20
|
+
return { userMcpServers: [], projectMcpServers: [] };
|
|
26
21
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const uniqueUserRules = dedupeByName(userRules);
|
|
46
|
-
return { userRules: uniqueUserRules, projectRules: uniqueWorkspaceRules };
|
|
22
|
+
if (!config)
|
|
23
|
+
return { userMcpServers: [], projectMcpServers: [] };
|
|
24
|
+
const groups = { user: [], project: [] };
|
|
25
|
+
for (const [serverName, serverConfig] of Object.entries(config.servers)) {
|
|
26
|
+
if (!isServerEnabled(serverConfig))
|
|
27
|
+
continue;
|
|
28
|
+
const source = config.sourceByServer[serverName];
|
|
29
|
+
if (!source)
|
|
30
|
+
continue;
|
|
31
|
+
groups[source.scope].push({ name: serverName, url: toNonEmptyString(serverConfig.url) });
|
|
32
|
+
}
|
|
33
|
+
for (const servers of Object.values(groups)) {
|
|
34
|
+
servers.sort((a, b) => a.name.localeCompare(b.name));
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
userMcpServers: groups.user,
|
|
38
|
+
projectMcpServers: groups.project,
|
|
39
|
+
};
|
|
47
40
|
}
|
|
48
41
|
function shortenHome(filePath) {
|
|
49
42
|
const home = homedir();
|
|
@@ -94,8 +87,14 @@ export default function sessionHintsExtension(pi) {
|
|
|
94
87
|
lines.push(theme.fg("dim", "option+m") + theme.fg("muted", " to cycle Plan/Ask/Agent mode"));
|
|
95
88
|
const userRules = details?.userRules ?? [];
|
|
96
89
|
const projectRules = details?.projectRules ?? [];
|
|
97
|
-
|
|
90
|
+
const userMcpServers = details?.userMcpServers ?? [];
|
|
91
|
+
const projectMcpServers = details?.projectMcpServers ?? [];
|
|
92
|
+
const hasRules = userRules.length > 0 || projectRules.length > 0;
|
|
93
|
+
const hasMcp = userMcpServers.length > 0 || projectMcpServers.length > 0;
|
|
94
|
+
if (hasRules || hasMcp) {
|
|
98
95
|
lines.push("");
|
|
96
|
+
}
|
|
97
|
+
if (hasRules) {
|
|
99
98
|
lines.push(theme.fg("mdHeading", "[Rules]"));
|
|
100
99
|
if (userRules.length > 0) {
|
|
101
100
|
lines.push(` ${theme.fg("accent", "user")}`);
|
|
@@ -110,6 +109,26 @@ export default function sessionHintsExtension(pi) {
|
|
|
110
109
|
}
|
|
111
110
|
}
|
|
112
111
|
}
|
|
112
|
+
if (hasRules && hasMcp) {
|
|
113
|
+
lines.push("");
|
|
114
|
+
}
|
|
115
|
+
if (hasMcp) {
|
|
116
|
+
lines.push(theme.fg("mdHeading", "[MCP Servers]"));
|
|
117
|
+
if (userMcpServers.length > 0) {
|
|
118
|
+
lines.push(` ${theme.fg("accent", "user")}`);
|
|
119
|
+
for (const server of userMcpServers) {
|
|
120
|
+
const urlPart = server.url ? ` ${theme.fg("muted", server.url)}` : "";
|
|
121
|
+
lines.push(` ${theme.fg("dim", server.name)}${urlPart}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (projectMcpServers.length > 0) {
|
|
125
|
+
lines.push(` ${theme.fg("accent", "project")}`);
|
|
126
|
+
for (const server of projectMcpServers) {
|
|
127
|
+
const urlPart = server.url ? ` ${theme.fg("muted", server.url)}` : "";
|
|
128
|
+
lines.push(` ${theme.fg("dim", server.name)}${urlPart}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
113
132
|
return new Text(lines.join("\n"), 0, 0);
|
|
114
133
|
});
|
|
115
134
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -118,7 +137,14 @@ export default function sessionHintsExtension(pi) {
|
|
|
118
137
|
const entries = ctx.sessionManager.getEntries();
|
|
119
138
|
if (hasMessageEntries(entries))
|
|
120
139
|
return;
|
|
121
|
-
const
|
|
140
|
+
const [rulesDetails, mcpDetails] = await Promise.all([
|
|
141
|
+
discoverRules(ctx.cwd),
|
|
142
|
+
discoverMcpServers(ctx.cwd),
|
|
143
|
+
]);
|
|
144
|
+
const details = {
|
|
145
|
+
...rulesDetails,
|
|
146
|
+
...mcpDetails,
|
|
147
|
+
};
|
|
122
148
|
pi.sendMessage({
|
|
123
149
|
customType: SESSION_HINTS_MESSAGE_TYPE,
|
|
124
150
|
content: "",
|
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, resolve } from "node:path";
|
|
5
3
|
import process from "node:process";
|
|
6
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
5
|
import { buildRuntimeEnvelope, createModeStateData, modeRuntimeStore, MODE_STATE_ENTRY_TYPE, ASK_MODE_SWITCH_REMINDER, PLAN_MODE_STILL_ACTIVE_REMINDER, } from "./mode-runtime.js";
|
|
8
|
-
import { createRpcRequestId,
|
|
6
|
+
import { createRpcRequestId, postJsonRpcRequest, MCP_PROTOCOL_VERSION, MCP_CLIENT_NAME, MCP_CLIENT_VERSION, } from "../mcp/protocol.js";
|
|
7
|
+
import { extractStringHeaders, isRecord, isServerEnabled, loadMcpConfig, toNonEmptyString, } from "../mcp/config.js";
|
|
8
|
+
import { discoverRules } from "../rules/discovery.js";
|
|
9
9
|
const PLAN_MODE_REMINDER_PATH = fileURLToPath(new URL("../../tools/plan-mode-reminder.md", import.meta.url));
|
|
10
10
|
const ASK_MODE_REMINDER_PATH = fileURLToPath(new URL("../../tools/ask-mode-reminder.md", import.meta.url));
|
|
11
|
-
const RULES_RELATIVE_DIR = join(".pi", "rules");
|
|
12
11
|
const MCP_INSTRUCTIONS_DESCRIPTION = "Instructions provided by MCP servers to help use them properly";
|
|
13
12
|
const USER_QUERY_RENDER_PATCH_FLAG = "__monoPilotUserQueryRenderPatched__";
|
|
14
13
|
function extractUserQueryForTuiDisplay(text) {
|
|
@@ -85,17 +84,16 @@ async function fetchServerInstructions(serverUrl, serverHeaders, serverName) {
|
|
|
85
84
|
}
|
|
86
85
|
}
|
|
87
86
|
async function buildMcpInstructionsEnvelope(workspaceCwd) {
|
|
88
|
-
|
|
89
|
-
if (!configPath)
|
|
90
|
-
return undefined;
|
|
91
|
-
let servers;
|
|
87
|
+
let config;
|
|
92
88
|
try {
|
|
93
|
-
|
|
89
|
+
config = await loadMcpConfig(workspaceCwd);
|
|
94
90
|
}
|
|
95
91
|
catch {
|
|
96
92
|
return undefined;
|
|
97
93
|
}
|
|
98
|
-
|
|
94
|
+
if (!config)
|
|
95
|
+
return undefined;
|
|
96
|
+
const serverEntries = Object.entries(config.servers)
|
|
99
97
|
.filter(([, config]) => isServerEnabled(config))
|
|
100
98
|
.map(([name, config]) => {
|
|
101
99
|
const serverName = normalizeServerLabel(name);
|
|
@@ -138,49 +136,26 @@ async function buildMcpInstructionsEnvelope(workspaceCwd) {
|
|
|
138
136
|
return lines.join("\n");
|
|
139
137
|
}
|
|
140
138
|
async function buildRulesEnvelope(workspaceCwd) {
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
139
|
+
const { userRules, projectRules } = await discoverRules(workspaceCwd);
|
|
140
|
+
const allRulePaths = [...userRules, ...projectRules];
|
|
141
|
+
if (allRulePaths.length === 0)
|
|
142
|
+
return undefined;
|
|
143
|
+
const ruleEntries = [];
|
|
144
|
+
for (const filePath of allRulePaths) {
|
|
147
145
|
try {
|
|
148
|
-
|
|
146
|
+
const content = await readFile(filePath, "utf-8");
|
|
147
|
+
const normalized = content.trim();
|
|
148
|
+
if (normalized.length > 0) {
|
|
149
|
+
ruleEntries.push({ filename: basename(filePath), content: normalized });
|
|
150
|
+
}
|
|
149
151
|
}
|
|
150
152
|
catch {
|
|
151
|
-
|
|
153
|
+
// Ignore unreadable rule files.
|
|
152
154
|
}
|
|
153
|
-
const ruleFileNames = directoryEntries
|
|
154
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith(".rule.txt"))
|
|
155
|
-
.map((entry) => entry.name)
|
|
156
|
-
.sort((a, b) => a.localeCompare(b));
|
|
157
|
-
const rules = new Map();
|
|
158
|
-
for (const ruleFileName of ruleFileNames) {
|
|
159
|
-
const ruleFilePath = resolve(dirPath, ruleFileName);
|
|
160
|
-
try {
|
|
161
|
-
const content = await readFile(ruleFilePath, "utf-8");
|
|
162
|
-
const normalized = content.trim();
|
|
163
|
-
if (normalized.length > 0) {
|
|
164
|
-
rules.set(ruleFileName, normalized);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
catch {
|
|
168
|
-
// Ignore unreadable rule files.
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return rules;
|
|
172
|
-
};
|
|
173
|
-
const userRules = await loadRulesFromDir(userRulesDirPath);
|
|
174
|
-
const workspaceRules = await loadRulesFromDir(rulesDirPath);
|
|
175
|
-
const mergedRules = new Map(userRules);
|
|
176
|
-
for (const [fileName, content] of workspaceRules) {
|
|
177
|
-
mergedRules.set(fileName, content);
|
|
178
155
|
}
|
|
179
|
-
if (
|
|
156
|
+
if (ruleEntries.length === 0)
|
|
180
157
|
return undefined;
|
|
181
|
-
const rules =
|
|
182
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
183
|
-
.map(([, content]) => content);
|
|
158
|
+
const rules = ruleEntries.sort((a, b) => a.filename.localeCompare(b.filename)).map((e) => e.content);
|
|
184
159
|
const lines = ["<rules>"];
|
|
185
160
|
for (const rule of rules) {
|
|
186
161
|
lines.push("<user_rule>");
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
export const MCP_CONFIG_RELATIVE_PATH = join(".pi", "mcp.json");
|
|
6
|
+
export function isRecord(value) {
|
|
7
|
+
return typeof value === "object" && value !== null;
|
|
8
|
+
}
|
|
9
|
+
export function toNonEmptyString(value) {
|
|
10
|
+
if (typeof value !== "string")
|
|
11
|
+
return undefined;
|
|
12
|
+
const trimmed = value.trim();
|
|
13
|
+
if (trimmed.length === 0)
|
|
14
|
+
return undefined;
|
|
15
|
+
return trimmed;
|
|
16
|
+
}
|
|
17
|
+
export function toBoolean(value) {
|
|
18
|
+
if (typeof value === "boolean")
|
|
19
|
+
return value;
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
export function formatErrorMessage(error) {
|
|
23
|
+
if (error instanceof Error)
|
|
24
|
+
return error.message;
|
|
25
|
+
return String(error);
|
|
26
|
+
}
|
|
27
|
+
export function getMcpConfigCandidates(workspaceCwd) {
|
|
28
|
+
return [resolve(workspaceCwd, MCP_CONFIG_RELATIVE_PATH), resolve(homedir(), MCP_CONFIG_RELATIVE_PATH)];
|
|
29
|
+
}
|
|
30
|
+
export function resolveMcpConfigSources(workspaceCwd) {
|
|
31
|
+
const projectPath = resolve(workspaceCwd, MCP_CONFIG_RELATIVE_PATH);
|
|
32
|
+
const userPath = resolve(homedir(), MCP_CONFIG_RELATIVE_PATH);
|
|
33
|
+
const sources = [];
|
|
34
|
+
if (existsSync(projectPath))
|
|
35
|
+
sources.push({ scope: "project", path: projectPath });
|
|
36
|
+
if (existsSync(userPath))
|
|
37
|
+
sources.push({ scope: "user", path: userPath });
|
|
38
|
+
return sources;
|
|
39
|
+
}
|
|
40
|
+
export async function loadMcpConfig(workspaceCwd) {
|
|
41
|
+
const sources = resolveMcpConfigSources(workspaceCwd);
|
|
42
|
+
if (sources.length === 0)
|
|
43
|
+
return undefined;
|
|
44
|
+
const servers = {};
|
|
45
|
+
const sourceByServer = {};
|
|
46
|
+
for (const source of sources) {
|
|
47
|
+
const parsed = await parseMcpConfig(source.path);
|
|
48
|
+
for (const [serverName, serverConfig] of Object.entries(parsed)) {
|
|
49
|
+
if (sourceByServer[serverName])
|
|
50
|
+
continue;
|
|
51
|
+
servers[serverName] = serverConfig;
|
|
52
|
+
sourceByServer[serverName] = source;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { servers, sources, sourceByServer };
|
|
56
|
+
}
|
|
57
|
+
async function parseMcpConfig(configPath) {
|
|
58
|
+
const rawText = await readFile(configPath, "utf-8");
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(rawText);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw new Error(`Invalid JSON in MCP config: ${formatErrorMessage(error)}`);
|
|
65
|
+
}
|
|
66
|
+
if (!isRecord(parsed)) {
|
|
67
|
+
throw new Error("MCP config root must be a JSON object.");
|
|
68
|
+
}
|
|
69
|
+
const serversValue = parsed.mcpServers;
|
|
70
|
+
if (!isRecord(serversValue))
|
|
71
|
+
return {};
|
|
72
|
+
const result = {};
|
|
73
|
+
for (const [serverName, serverConfig] of Object.entries(serversValue)) {
|
|
74
|
+
if (!isRecord(serverConfig))
|
|
75
|
+
continue;
|
|
76
|
+
result[serverName] = serverConfig;
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
export function isServerEnabled(config) {
|
|
81
|
+
const disabled = toBoolean(config.disabled);
|
|
82
|
+
if (disabled === true)
|
|
83
|
+
return false;
|
|
84
|
+
const enabled = toBoolean(config.enabled);
|
|
85
|
+
if (enabled === false)
|
|
86
|
+
return false;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
export function inferTransport(config) {
|
|
90
|
+
if (toNonEmptyString(config.url))
|
|
91
|
+
return "remote";
|
|
92
|
+
if (toNonEmptyString(config.command))
|
|
93
|
+
return "stdio";
|
|
94
|
+
return "unknown";
|
|
95
|
+
}
|
|
96
|
+
export function extractStringHeaders(rawHeaders) {
|
|
97
|
+
if (!isRecord(rawHeaders))
|
|
98
|
+
return {};
|
|
99
|
+
const headers = {};
|
|
100
|
+
for (const [key, value] of Object.entries(rawHeaders)) {
|
|
101
|
+
const headerName = key.trim();
|
|
102
|
+
if (!headerName)
|
|
103
|
+
continue;
|
|
104
|
+
if (typeof value === "string")
|
|
105
|
+
headers[headerName] = value;
|
|
106
|
+
}
|
|
107
|
+
return headers;
|
|
108
|
+
}
|
|
109
|
+
export function getHeaderKeys(headers) {
|
|
110
|
+
const keys = Object.keys(headers).sort((a, b) => a.localeCompare(b));
|
|
111
|
+
return keys.length > 0 ? keys : undefined;
|
|
112
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { isRecord, toNonEmptyString } from "./config.js";
|
|
2
|
+
export const MCP_PROTOCOL_VERSION = "2025-03-26";
|
|
3
|
+
export const MCP_CLIENT_NAME = "mono-pilot";
|
|
4
|
+
export const MCP_CLIENT_VERSION = "0.1.0";
|
|
5
|
+
export const MCP_REQUEST_TIMEOUT_MS = 20_000;
|
|
6
|
+
export function parseSseJsonPayload(rawBody) {
|
|
7
|
+
const lines = rawBody.split(/\r?\n/);
|
|
8
|
+
const events = [];
|
|
9
|
+
let currentDataLines = [];
|
|
10
|
+
for (const line of lines) {
|
|
11
|
+
if (line.length === 0) {
|
|
12
|
+
if (currentDataLines.length > 0) {
|
|
13
|
+
events.push(currentDataLines.join("\n"));
|
|
14
|
+
currentDataLines = [];
|
|
15
|
+
}
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (line.startsWith("data:")) {
|
|
19
|
+
currentDataLines.push(line.slice(5).trimStart());
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (currentDataLines.length > 0) {
|
|
23
|
+
events.push(currentDataLines.join("\n"));
|
|
24
|
+
}
|
|
25
|
+
let parsed;
|
|
26
|
+
for (const eventData of events) {
|
|
27
|
+
if (!eventData || eventData === "[DONE]")
|
|
28
|
+
continue;
|
|
29
|
+
try {
|
|
30
|
+
const candidate = JSON.parse(eventData);
|
|
31
|
+
if (isRecord(candidate))
|
|
32
|
+
parsed = candidate;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Ignore malformed SSE chunks.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
40
|
+
export function parseJsonRpcBody(rawBody, contentType) {
|
|
41
|
+
const trimmed = rawBody.trim();
|
|
42
|
+
if (trimmed.length === 0)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (contentType?.includes("text/event-stream")) {
|
|
45
|
+
const sseParsed = parseSseJsonPayload(rawBody);
|
|
46
|
+
if (sseParsed)
|
|
47
|
+
return sseParsed;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(trimmed);
|
|
51
|
+
if (isRecord(parsed))
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
const sseParsed = parseSseJsonPayload(rawBody);
|
|
56
|
+
if (sseParsed)
|
|
57
|
+
return sseParsed;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
export function formatJsonRpcError(error, fallback) {
|
|
62
|
+
if (!error)
|
|
63
|
+
return fallback;
|
|
64
|
+
const codePart = typeof error.code === "number" ? ` (code ${error.code})` : "";
|
|
65
|
+
const messagePart = toNonEmptyString(error.message) ?? fallback;
|
|
66
|
+
return `${messagePart}${codePart}`;
|
|
67
|
+
}
|
|
68
|
+
export function createRpcRequestId(prefix) {
|
|
69
|
+
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
70
|
+
}
|
|
71
|
+
export async function postJsonRpcRequest(options) {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs ?? MCP_REQUEST_TIMEOUT_MS);
|
|
74
|
+
const onAbort = () => controller.abort();
|
|
75
|
+
options.parentSignal?.addEventListener("abort", onAbort, { once: true });
|
|
76
|
+
try {
|
|
77
|
+
const requestHeaders = {
|
|
78
|
+
"content-type": "application/json",
|
|
79
|
+
accept: "application/json, text/event-stream",
|
|
80
|
+
...options.headers,
|
|
81
|
+
};
|
|
82
|
+
if (options.sessionId) {
|
|
83
|
+
requestHeaders["mcp-session-id"] = options.sessionId;
|
|
84
|
+
}
|
|
85
|
+
const response = await fetch(options.url, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: requestHeaders,
|
|
88
|
+
body: JSON.stringify(options.body),
|
|
89
|
+
signal: controller.signal,
|
|
90
|
+
});
|
|
91
|
+
const rawBody = await response.text();
|
|
92
|
+
const contentType = toNonEmptyString(response.headers.get("content-type") ?? undefined);
|
|
93
|
+
const parsedBody = parseJsonRpcBody(rawBody, contentType);
|
|
94
|
+
const responseSessionId = toNonEmptyString(response.headers.get("mcp-session-id") ?? undefined) ??
|
|
95
|
+
toNonEmptyString(response.headers.get("Mcp-Session-Id") ?? undefined);
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
const fallback = `Remote MCP request failed with status ${response.status}.`;
|
|
98
|
+
const errorText = formatJsonRpcError(parsedBody?.error, fallback) || toNonEmptyString(rawBody) || fallback;
|
|
99
|
+
throw new Error(errorText);
|
|
100
|
+
}
|
|
101
|
+
if (options.expectResponseBody && !parsedBody) {
|
|
102
|
+
throw new Error("Remote MCP response did not contain a JSON-RPC body.");
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
parsedBody,
|
|
106
|
+
rawBody,
|
|
107
|
+
sessionId: responseSessionId,
|
|
108
|
+
status: response.status,
|
|
109
|
+
contentType,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
clearTimeout(timeoutId);
|
|
114
|
+
options.parentSignal?.removeEventListener("abort", onAbort);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export async function initializeMcpSession(options) {
|
|
118
|
+
const initializeResponse = await postJsonRpcRequest({
|
|
119
|
+
url: options.serverUrl,
|
|
120
|
+
headers: options.serverHeaders,
|
|
121
|
+
body: {
|
|
122
|
+
jsonrpc: "2.0",
|
|
123
|
+
id: createRpcRequestId(`${options.toolCallId}:initialize`),
|
|
124
|
+
method: "initialize",
|
|
125
|
+
params: {
|
|
126
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
127
|
+
capabilities: {},
|
|
128
|
+
clientInfo: {
|
|
129
|
+
name: MCP_CLIENT_NAME,
|
|
130
|
+
version: MCP_CLIENT_VERSION,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
parentSignal: options.signal,
|
|
135
|
+
expectResponseBody: true,
|
|
136
|
+
});
|
|
137
|
+
const initializeBody = initializeResponse.parsedBody;
|
|
138
|
+
if (initializeBody?.error) {
|
|
139
|
+
throw new Error(formatJsonRpcError(initializeBody.error, "MCP initialize failed."));
|
|
140
|
+
}
|
|
141
|
+
if (!initializeBody || initializeBody.result === undefined) {
|
|
142
|
+
throw new Error("MCP initialize did not return a result.");
|
|
143
|
+
}
|
|
144
|
+
let sessionId = initializeResponse.sessionId;
|
|
145
|
+
try {
|
|
146
|
+
const initializedNotification = await postJsonRpcRequest({
|
|
147
|
+
url: options.serverUrl,
|
|
148
|
+
headers: options.serverHeaders,
|
|
149
|
+
body: {
|
|
150
|
+
jsonrpc: "2.0",
|
|
151
|
+
method: "notifications/initialized",
|
|
152
|
+
params: {},
|
|
153
|
+
},
|
|
154
|
+
parentSignal: options.signal,
|
|
155
|
+
sessionId,
|
|
156
|
+
expectResponseBody: false,
|
|
157
|
+
});
|
|
158
|
+
sessionId = initializedNotification.sessionId ?? sessionId;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Best effort compatibility.
|
|
162
|
+
}
|
|
163
|
+
return sessionId;
|
|
164
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { extractStringHeaders, formatErrorMessage, getMcpConfigCandidates, inferTransport, isServerEnabled, loadMcpConfig, resolveMcpConfigSources, toNonEmptyString, } from "./config.js";
|
|
2
|
+
/** Thrown when no MCP config files are found or config fails to parse. */
|
|
3
|
+
export class McpConfigError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "McpConfigError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** Thrown when a specific server can't be used (not found, disabled, wrong transport, etc.). */
|
|
10
|
+
export class McpServerError extends Error {
|
|
11
|
+
transport;
|
|
12
|
+
configPaths;
|
|
13
|
+
constructor(message, transport, configPaths) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.transport = transport;
|
|
16
|
+
this.configPaths = configPaths;
|
|
17
|
+
this.name = "McpServerError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function loadRequiredMcpConfig(cwd) {
|
|
21
|
+
const sources = resolveMcpConfigSources(cwd);
|
|
22
|
+
if (sources.length === 0) {
|
|
23
|
+
const candidates = getMcpConfigCandidates(cwd);
|
|
24
|
+
throw new McpConfigError(`MCP config not found. Checked:\n- ${candidates.join("\n- ")}`);
|
|
25
|
+
}
|
|
26
|
+
let config;
|
|
27
|
+
try {
|
|
28
|
+
config = await loadMcpConfig(cwd);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
throw new McpConfigError(formatErrorMessage(error));
|
|
32
|
+
}
|
|
33
|
+
if (!config) {
|
|
34
|
+
const candidates = getMcpConfigCandidates(cwd);
|
|
35
|
+
throw new McpConfigError(`MCP config not found. Checked:\n- ${candidates.join("\n- ")}`);
|
|
36
|
+
}
|
|
37
|
+
return config;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* For list-style tools (ListMcpTools, ListMcpResources).
|
|
41
|
+
* Returns all enabled remote servers matching the optional name filter.
|
|
42
|
+
* Throws McpConfigError on config load failure.
|
|
43
|
+
*/
|
|
44
|
+
export async function resolveTargetServers(cwd, serverFilter) {
|
|
45
|
+
const config = await loadRequiredMcpConfig(cwd);
|
|
46
|
+
const configPaths = config.sources.map((source) => source.path);
|
|
47
|
+
const servers = [];
|
|
48
|
+
for (const [serverName, serverConfig] of Object.entries(config.servers)) {
|
|
49
|
+
if (serverFilter && serverName !== serverFilter)
|
|
50
|
+
continue;
|
|
51
|
+
if (!isServerEnabled(serverConfig))
|
|
52
|
+
continue;
|
|
53
|
+
if (inferTransport(serverConfig) !== "remote")
|
|
54
|
+
continue;
|
|
55
|
+
const serverUrl = toNonEmptyString(serverConfig.url);
|
|
56
|
+
if (!serverUrl)
|
|
57
|
+
continue;
|
|
58
|
+
servers.push({ name: serverName, url: serverUrl, headers: extractStringHeaders(serverConfig.headers) });
|
|
59
|
+
}
|
|
60
|
+
return { servers, sources: config.sources, configPaths };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* For single-server tools (CallMcpTool, FetchMcpResource).
|
|
64
|
+
* Returns the resolved remote server or throws McpConfigError / McpServerError.
|
|
65
|
+
*/
|
|
66
|
+
export async function resolveTargetServer(cwd, serverName) {
|
|
67
|
+
const config = await loadRequiredMcpConfig(cwd);
|
|
68
|
+
const configPaths = config.sources.map((source) => source.path);
|
|
69
|
+
const serverConfig = config.servers[serverName];
|
|
70
|
+
if (!serverConfig) {
|
|
71
|
+
throw new McpServerError(`MCP server '${serverName}' not found in configured MCP sources.`, undefined, configPaths);
|
|
72
|
+
}
|
|
73
|
+
if (!isServerEnabled(serverConfig)) {
|
|
74
|
+
throw new McpServerError(`MCP server '${serverName}' is disabled in config.`, inferTransport(serverConfig), configPaths);
|
|
75
|
+
}
|
|
76
|
+
const transport = inferTransport(serverConfig);
|
|
77
|
+
if (transport === "stdio") {
|
|
78
|
+
const command = toNonEmptyString(serverConfig.command);
|
|
79
|
+
const message = `MCP stdio transport is not supported yet.` + (command ? ` Configured command: ${command}` : "");
|
|
80
|
+
throw new McpServerError(message, transport, configPaths);
|
|
81
|
+
}
|
|
82
|
+
const serverUrl = toNonEmptyString(serverConfig.url);
|
|
83
|
+
if (!serverUrl) {
|
|
84
|
+
throw new McpServerError(`MCP server '${serverName}' is missing a remote URL.`, transport, configPaths);
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
server: { name: serverName, url: serverUrl, headers: extractStringHeaders(serverConfig.headers) },
|
|
88
|
+
configPaths,
|
|
89
|
+
};
|
|
90
|
+
}
|