omnirush 0.3.3 → 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.
- package/assets/extensions/omnirush/collector-lib.ts +64 -29
- package/assets/extensions/omnirush/collector.ts +10 -2
- package/assets/extensions/omnirush/commands.ts +148 -23
- package/assets/extensions/omnirush/index.ts +4 -0
- package/assets/extensions/omnirush/mcp-client.bundle.js +19432 -0
- package/assets/extensions/omnirush/mcp-lib.ts +199 -0
- package/assets/extensions/omnirush/mcp.ts +379 -0
- package/assets/extensions/omnirush/status-lib.ts +85 -0
- package/assets/extensions/omnirush/usage.ts +77 -0
- package/package.json +7 -2
- package/scripts/build-mcp-bundle.js +47 -0
- package/scripts/patch-pi-branding.js +27 -0
- package/src/bin.js +46 -4
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Pure helpers for the Omnirush MCP bridge: config parsing/validation,
|
|
2
|
+
// tool naming, and JSON-schema -> typebox mapping for pi tool parameters.
|
|
3
|
+
//
|
|
4
|
+
// No SDK import and no I/O here (except loadMcpConfigFile) so node:test
|
|
5
|
+
// covers everything directly. The typebox import resolves through pi's
|
|
6
|
+
// jiti alias at runtime and through a test alias under node:test.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
|
|
11
|
+
export const MCP_CONFIG_FILE = "mcp.json";
|
|
12
|
+
export const MCP_TOOL_PREFIX = "mcp__";
|
|
13
|
+
|
|
14
|
+
/** Claude-style server entry: stdio (command/args/env) or HTTP (url). */
|
|
15
|
+
export interface McpServerConfig {
|
|
16
|
+
command?: string;
|
|
17
|
+
args?: string[];
|
|
18
|
+
env?: Record<string, string>;
|
|
19
|
+
url?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ParsedMcpConfig {
|
|
23
|
+
/** Valid server configs keyed by server name, in file order. */
|
|
24
|
+
servers: Record<string, McpServerConfig>;
|
|
25
|
+
/** Human-readable problems, one per skipped/invalid entry. */
|
|
26
|
+
errors: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
30
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function validateEntry(raw: unknown, name: string): { config?: McpServerConfig; error?: string } {
|
|
34
|
+
if (!isPlainObject(raw)) return { error: `${name}: entry must be an object` };
|
|
35
|
+
const command = typeof raw.command === "string" ? raw.command.trim() : "";
|
|
36
|
+
const url = typeof raw.url === "string" ? raw.url.trim() : "";
|
|
37
|
+
if (command && url) {
|
|
38
|
+
return { error: `${name}: has both command and url — pick one transport` };
|
|
39
|
+
}
|
|
40
|
+
if (command) {
|
|
41
|
+
const args = raw.args === undefined ? [] : raw.args;
|
|
42
|
+
if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) {
|
|
43
|
+
return { error: `${name}: args must be an array of strings` };
|
|
44
|
+
}
|
|
45
|
+
let env: Record<string, string> | undefined;
|
|
46
|
+
if (raw.env !== undefined) {
|
|
47
|
+
if (!isPlainObject(raw.env)) return { error: `${name}: env must be an object of strings` };
|
|
48
|
+
env = {};
|
|
49
|
+
for (const [key, value] of Object.entries(raw.env)) {
|
|
50
|
+
if (typeof value !== "string") return { error: `${name}: env.${key} must be a string` };
|
|
51
|
+
env[key] = value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { config: { command, ...(args.length > 0 ? { args: args as string[] } : {}), ...(env ? { env } : {}) } };
|
|
55
|
+
}
|
|
56
|
+
if (url) {
|
|
57
|
+
let parsed: URL;
|
|
58
|
+
try {
|
|
59
|
+
parsed = new URL(url);
|
|
60
|
+
} catch {
|
|
61
|
+
return { error: `${name}: url is not a valid URL` };
|
|
62
|
+
}
|
|
63
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
64
|
+
return { error: `${name}: url must be http(s)` };
|
|
65
|
+
}
|
|
66
|
+
return { config: { url } };
|
|
67
|
+
}
|
|
68
|
+
return { error: `${name}: needs a command (stdio) or url (HTTP)` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Parse a claude-style {"mcpServers": {...}} payload. Invalid entries are skipped (never fatal). */
|
|
72
|
+
export function parseMcpConfig(raw: unknown): ParsedMcpConfig {
|
|
73
|
+
const errors: string[] = [];
|
|
74
|
+
if (!isPlainObject(raw)) {
|
|
75
|
+
return { servers: {}, errors: raw === undefined || raw === null ? [] : ["config root must be an object"] };
|
|
76
|
+
}
|
|
77
|
+
const serversRaw = raw.mcpServers ?? raw.servers;
|
|
78
|
+
if (serversRaw === undefined) return { servers: {}, errors };
|
|
79
|
+
if (!isPlainObject(serversRaw)) return { servers: {}, errors: ["mcpServers must be an object"] };
|
|
80
|
+
const servers: Record<string, McpServerConfig> = {};
|
|
81
|
+
for (const [name, entry] of Object.entries(serversRaw)) {
|
|
82
|
+
const { config, error } = validateEntry(entry, name);
|
|
83
|
+
if (error || !config) {
|
|
84
|
+
errors.push(error ?? `${name}: invalid entry`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
servers[name] = config;
|
|
88
|
+
}
|
|
89
|
+
return { servers, errors };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read + parse ~/.omnirush/mcp.json (or MCP_CONFIG_FILE in dir). A missing
|
|
94
|
+
* file is an empty config, not an error; a malformed file yields one error
|
|
95
|
+
* line and no servers — a broken config must never break the session.
|
|
96
|
+
*/
|
|
97
|
+
export function loadMcpConfigFile(filePath: string): ParsedMcpConfig {
|
|
98
|
+
let text: string;
|
|
99
|
+
try {
|
|
100
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
101
|
+
} catch (error: any) {
|
|
102
|
+
if (error?.code === "ENOENT") return { servers: {}, errors: [] };
|
|
103
|
+
return { servers: {}, errors: [`cannot read config: ${error?.message ?? error}`] };
|
|
104
|
+
}
|
|
105
|
+
let raw: unknown;
|
|
106
|
+
try {
|
|
107
|
+
raw = JSON.parse(text);
|
|
108
|
+
} catch (error: any) {
|
|
109
|
+
return { servers: {}, errors: [`config is not valid JSON (${error?.message ?? error})`] };
|
|
110
|
+
}
|
|
111
|
+
return parseMcpConfig(raw);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Sanitize one name segment for a pi tool name: [A-Za-z0-9_-]+, <= 64 chars. */
|
|
115
|
+
export function sanitizeToolPart(name: string): string {
|
|
116
|
+
const cleaned = String(name)
|
|
117
|
+
.replace(/[^A-Za-z0-9_-]+/g, "-")
|
|
118
|
+
.replace(/^-+|-+$/g, "")
|
|
119
|
+
.slice(0, 64);
|
|
120
|
+
return cleaned || "unknown";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The pi tool name for an MCP tool: mcp__<server>__<tool>, capped at 64
|
|
124
|
+
* chars (the OpenAI function-name limit the gateway enforces upstream). */
|
|
125
|
+
export function mcpToolName(server: string, tool: string): string {
|
|
126
|
+
const name = `${MCP_TOOL_PREFIX}${sanitizeToolPart(server)}__${sanitizeToolPart(tool)}`;
|
|
127
|
+
return name.length > 64 ? name.slice(0, 64) : name;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Dedupe registered tool names (two servers can expose the same tool name
|
|
132
|
+
* after sanitization): the first keeps its name, later ones get -2, -3...
|
|
133
|
+
*/
|
|
134
|
+
export function dedupeToolNames(names: string[]): string[] {
|
|
135
|
+
const seen = new Map<string, number>();
|
|
136
|
+
return names.map((name) => {
|
|
137
|
+
const count = seen.get(name) ?? 0;
|
|
138
|
+
seen.set(name, count + 1);
|
|
139
|
+
return count === 0 ? name : `${name}-${count + 1}`;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- JSON schema -> typebox ------------------------------------------------
|
|
144
|
+
|
|
145
|
+
function schemaObject(schema: any): Record<string, any> {
|
|
146
|
+
const out: Record<string, any> = {};
|
|
147
|
+
if (typeof schema?.description === "string" && schema.description) {
|
|
148
|
+
out.description = schema.description;
|
|
149
|
+
}
|
|
150
|
+
if (schema?.default !== undefined) out.default = schema.default;
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Map one JSON-schema node to a typebox schema (Type.Any fallback). */
|
|
155
|
+
export function jsonSchemaToTypeBox(schema: unknown): any {
|
|
156
|
+
const s = schema as any;
|
|
157
|
+
if (!isPlainObject(s)) return Type.Any();
|
|
158
|
+
const opts = schemaObject(s);
|
|
159
|
+
switch (s.type) {
|
|
160
|
+
case "string":
|
|
161
|
+
return Array.isArray(s.enum) && s.enum.every((e: unknown) => typeof e === "string") && s.enum.length > 0
|
|
162
|
+
? Type.Union(s.enum.map((e: string) => Type.Literal(e)), opts)
|
|
163
|
+
: Type.String(opts);
|
|
164
|
+
case "number":
|
|
165
|
+
case "integer":
|
|
166
|
+
return Type.Number(opts);
|
|
167
|
+
case "boolean":
|
|
168
|
+
return Type.Boolean(opts);
|
|
169
|
+
case "array":
|
|
170
|
+
return Type.Array(jsonSchemaToTypeBox(s.items), opts);
|
|
171
|
+
case "object": {
|
|
172
|
+
const properties: Record<string, any> = {};
|
|
173
|
+
const required = new Set<string>(
|
|
174
|
+
Array.isArray(s.required) ? s.required.filter((r: unknown) => typeof r === "string") : [],
|
|
175
|
+
);
|
|
176
|
+
for (const [key, prop] of Object.entries(s.properties ?? {})) {
|
|
177
|
+
const mapped = jsonSchemaToTypeBox(prop);
|
|
178
|
+
properties[key] = required.has(key) ? mapped : Type.Optional(mapped);
|
|
179
|
+
}
|
|
180
|
+
return Type.Object(properties, opts);
|
|
181
|
+
}
|
|
182
|
+
case "null":
|
|
183
|
+
return Type.Null(opts);
|
|
184
|
+
default:
|
|
185
|
+
// Untyped or union nodes: accept anything rather than reject valid
|
|
186
|
+
// MCP calls at validation.
|
|
187
|
+
return Type.Any(opts);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* pi tool parameters for an MCP tool: always an object (MCP tool calls
|
|
193
|
+
* take a JSON object `arguments`); a missing input schema yields {}.
|
|
194
|
+
*/
|
|
195
|
+
export function parametersForTool(inputSchema: unknown): any {
|
|
196
|
+
const mapped = jsonSchemaToTypeBox(inputSchema);
|
|
197
|
+
if (mapped?.type === "object") return mapped;
|
|
198
|
+
return Type.Object({}, { description: "this tool takes no structured arguments" });
|
|
199
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// Omnirush MCP bridge — bring MCP servers' tools into pi sessions.
|
|
2
|
+
//
|
|
3
|
+
// pi has no MCP support, but extensions can register custom tools
|
|
4
|
+
// (pi.registerTool). This extension:
|
|
5
|
+
// - reads ~/.omnirush/mcp.json (claude-style
|
|
6
|
+
// {"mcpServers": {"<name>": {command, args, env} | {url}}}),
|
|
7
|
+
// - on session start, connects every configured server — lazily spawned
|
|
8
|
+
// here but bounded: each connect has a timeout and per-server error
|
|
9
|
+
// isolation, so a dead server can never break the session or the
|
|
10
|
+
// other servers,
|
|
11
|
+
// - lists each server's tools and registers EACH as a pi tool named
|
|
12
|
+
// mcp__<server>__<tool> with a JSON-schema-mapped input schema,
|
|
13
|
+
// - forwards tool calls to the right server and surfaces MCP errors
|
|
14
|
+
// (transport failures and isError results) as pi tool errors,
|
|
15
|
+
// - serves /mcp: configured servers, connection state, tool counts,
|
|
16
|
+
// and reconnect (optionally for one server).
|
|
17
|
+
//
|
|
18
|
+
// The MCP SDK is imported from the committed sibling bundle
|
|
19
|
+
// (mcp-client.bundle.js): pi copies this directory into
|
|
20
|
+
// ~/.pi/agent/extensions/omnirush/ and jiti there resolves only sibling
|
|
21
|
+
// files, never npm packages.
|
|
22
|
+
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { Client, McpError, StdioClientTransport, StreamableHTTPClientTransport } from "./mcp-client.bundle.js";
|
|
25
|
+
import { omniDir } from "./auth";
|
|
26
|
+
import {
|
|
27
|
+
dedupeToolNames,
|
|
28
|
+
loadMcpConfigFile,
|
|
29
|
+
mcpToolName,
|
|
30
|
+
MCP_CONFIG_FILE,
|
|
31
|
+
parametersForTool,
|
|
32
|
+
type McpServerConfig,
|
|
33
|
+
} from "./mcp-lib";
|
|
34
|
+
import { truncateTail } from "@earendil-works/pi-coding-agent";
|
|
35
|
+
|
|
36
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
37
|
+
|
|
38
|
+
// Bounded lifecycles: a hung server must never wedge the session start
|
|
39
|
+
// (connect) or a turn (tool call). Timeouts land as ordinary errors in
|
|
40
|
+
// the per-server state and as tool errors, respectively. Env overrides
|
|
41
|
+
// exist for tests (MCP_*_TIMEOUT_MS); production defaults are generous.
|
|
42
|
+
function timeoutFromEnv(name: string, fallback: number): number {
|
|
43
|
+
const value = Number(process.env[name]);
|
|
44
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
45
|
+
}
|
|
46
|
+
const CONNECT_TIMEOUT_MS = timeoutFromEnv("MCP_CONNECT_TIMEOUT_MS", 15_000);
|
|
47
|
+
const LIST_TIMEOUT_MS = timeoutFromEnv("MCP_LIST_TIMEOUT_MS", 15_000);
|
|
48
|
+
const CALL_TIMEOUT_MS = timeoutFromEnv("MCP_CALL_TIMEOUT_MS", 120_000);
|
|
49
|
+
// Tool output cap (pi's built-in guidance: ~50KB / 2000 lines).
|
|
50
|
+
const OUTPUT_MAX_BYTES = 50 * 1024;
|
|
51
|
+
// Shutdown budget so quitting is never blocked by a wedged server.
|
|
52
|
+
const SHUTDOWN_BUDGET_MS = 3_000;
|
|
53
|
+
// Stderr lines kept per stdio server for failure diagnostics.
|
|
54
|
+
const STDERR_TAIL_LINES = 5;
|
|
55
|
+
|
|
56
|
+
type ServerStatus = "connecting" | "connected" | "failed";
|
|
57
|
+
|
|
58
|
+
/** OMNIRUSH_DEBUG=1 diagnostics (same convention as the sota guard). */
|
|
59
|
+
function debug(line: string): void {
|
|
60
|
+
if (process.env.OMNIRUSH_DEBUG !== "1") return;
|
|
61
|
+
try {
|
|
62
|
+
process.stderr.write(`omnirush mcp: ${line}\n`);
|
|
63
|
+
} catch {
|
|
64
|
+
/* stderr may be closed late in shutdown */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface McpToolInfo {
|
|
69
|
+
name: string;
|
|
70
|
+
description?: string;
|
|
71
|
+
inputSchema?: unknown;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface ServerState {
|
|
75
|
+
name: string;
|
|
76
|
+
config: McpServerConfig;
|
|
77
|
+
status: ServerStatus;
|
|
78
|
+
error?: string;
|
|
79
|
+
tools: McpToolInfo[];
|
|
80
|
+
client?: Client;
|
|
81
|
+
stderrTail: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
85
|
+
return new Promise<T>((resolve, reject) => {
|
|
86
|
+
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms);
|
|
87
|
+
timer.unref?.();
|
|
88
|
+
promise.then(
|
|
89
|
+
(value) => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
resolve(value);
|
|
92
|
+
},
|
|
93
|
+
(error) => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
reject(error);
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** MCP content blocks -> pi tool-result content (text/image, rest summarized). */
|
|
102
|
+
function mcpContentToPiContent(content: unknown): Array<Record<string, any>> {
|
|
103
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
104
|
+
const out: Array<Record<string, any>> = [];
|
|
105
|
+
for (const block of blocks as Array<any>) {
|
|
106
|
+
if (block?.type === "text" && typeof block.text === "string") {
|
|
107
|
+
out.push({ type: "text", text: block.text });
|
|
108
|
+
} else if (block?.type === "image" && typeof block.data === "string") {
|
|
109
|
+
out.push({ type: "image", data: block.data, mimeType: String(block.mimeType ?? "image/png") });
|
|
110
|
+
} else if (block?.type === "audio") {
|
|
111
|
+
out.push({
|
|
112
|
+
type: "text",
|
|
113
|
+
text: `[audio content omitted: ${block.mimeType ?? "unknown"}, ${String(block.data ?? "").length} base64 chars]`,
|
|
114
|
+
});
|
|
115
|
+
} else if (block?.type === "resource" && block.resource) {
|
|
116
|
+
const resource = block.resource;
|
|
117
|
+
if (typeof resource.text === "string") {
|
|
118
|
+
out.push({ type: "text", text: resource.text });
|
|
119
|
+
} else {
|
|
120
|
+
out.push({
|
|
121
|
+
type: "text",
|
|
122
|
+
text: `[binary resource omitted: ${resource.uri ?? resource.name ?? "unknown"}, ${String(resource.blob ?? "").length} base64 chars]`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
} else if (block && typeof block === "object") {
|
|
126
|
+
out.push({ type: "text", text: JSON.stringify(block) });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function contentText(content: Array<Record<string, any>>): string {
|
|
133
|
+
return content
|
|
134
|
+
.filter((block) => block.type === "text")
|
|
135
|
+
.map((block) => block.text)
|
|
136
|
+
.join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Connect one server (bounded). Throws on failure — caller isolates. */
|
|
140
|
+
async function connectServer(state: ServerState): Promise<void> {
|
|
141
|
+
const client = new Client({
|
|
142
|
+
name: "omnirush",
|
|
143
|
+
version: (process.env.OMNIRUSH_VERSION || "").trim() || "0.0.0",
|
|
144
|
+
});
|
|
145
|
+
let transport: any;
|
|
146
|
+
if (state.config.url) {
|
|
147
|
+
transport = new StreamableHTTPClientTransport(new URL(state.config.url));
|
|
148
|
+
} else {
|
|
149
|
+
transport = new StdioClientTransport({
|
|
150
|
+
command: state.config.command!,
|
|
151
|
+
args: state.config.args ?? [],
|
|
152
|
+
...(state.config.env ? { env: state.config.env } : {}),
|
|
153
|
+
stderr: "pipe",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
state.client = client;
|
|
157
|
+
client.onerror = (error) => {
|
|
158
|
+
if (state.status === "connected") {
|
|
159
|
+
state.status = "failed";
|
|
160
|
+
state.error = error?.message ?? String(error);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
if (transport.stderr) {
|
|
164
|
+
transport.stderr.on("data", (chunk: Buffer | string) => {
|
|
165
|
+
const line = String(chunk).trim();
|
|
166
|
+
if (!line) return;
|
|
167
|
+
state.stderrTail.push(line);
|
|
168
|
+
if (state.stderrTail.length > STDERR_TAIL_LINES) state.stderrTail.shift();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `${state.name}: connect`);
|
|
173
|
+
const listed = await client.listTools({}, { timeout: LIST_TIMEOUT_MS });
|
|
174
|
+
state.tools = (listed.tools ?? []).map((tool: any) => ({
|
|
175
|
+
name: String(tool.name),
|
|
176
|
+
...(typeof tool.description === "string" ? { description: tool.description } : {}),
|
|
177
|
+
...(tool.inputSchema !== undefined ? { inputSchema: tool.inputSchema } : {}),
|
|
178
|
+
}));
|
|
179
|
+
state.status = "connected";
|
|
180
|
+
state.error = undefined;
|
|
181
|
+
debug(`${state.name}: connected, ${state.tools.length} tools`);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
state.status = "failed";
|
|
184
|
+
state.error = error?.message ?? String(error);
|
|
185
|
+
debug(`${state.name}: connect failed — ${state.error}`);
|
|
186
|
+
// Best-effort teardown so a half-open transport does not linger.
|
|
187
|
+
try {
|
|
188
|
+
await client.close();
|
|
189
|
+
} catch {
|
|
190
|
+
/* already dead */
|
|
191
|
+
}
|
|
192
|
+
state.client = undefined;
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function describeServer(state: ServerState): string {
|
|
198
|
+
if (state.config.url) return `${state.name} (${state.config.url})`;
|
|
199
|
+
const args = (state.config.args ?? []).join(" ");
|
|
200
|
+
return `${state.name} (${[state.config.command, args].filter(Boolean).join(" ")})`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export default function (pi: any) {
|
|
204
|
+
const servers = new Map<string, ServerState>();
|
|
205
|
+
// Ordered pi tool names per server (mcp__<server>__<tool>).
|
|
206
|
+
const registeredByServer = new Map<string, string[]>();
|
|
207
|
+
|
|
208
|
+
const loadConfig = (): { found: boolean; errors: string[] } => {
|
|
209
|
+
const parsed = loadMcpConfigFile(path.join(omniDir(), MCP_CONFIG_FILE));
|
|
210
|
+
servers.clear();
|
|
211
|
+
for (const [name, config] of Object.entries(parsed.servers)) {
|
|
212
|
+
servers.set(name, { name, config, status: "connecting", tools: [], stderrTail: [] });
|
|
213
|
+
}
|
|
214
|
+
return { found: Object.keys(parsed.servers).length > 0, errors: parsed.errors };
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const registerServerTools = (state: ServerState): void => {
|
|
218
|
+
const names = dedupeToolNames(state.tools.map((tool) => mcpToolName(state.name, tool.name)));
|
|
219
|
+
const registered: string[] = [];
|
|
220
|
+
state.tools.forEach((tool, index) => {
|
|
221
|
+
const toolName = names[index];
|
|
222
|
+
registered.push(toolName);
|
|
223
|
+
pi.registerTool({
|
|
224
|
+
name: toolName,
|
|
225
|
+
label: `${state.name}: ${tool.name}`,
|
|
226
|
+
description:
|
|
227
|
+
tool.description ||
|
|
228
|
+
`MCP tool ${tool.name} from server "${state.name}"`,
|
|
229
|
+
parameters: parametersForTool(tool.inputSchema),
|
|
230
|
+
async execute(toolCallId: string, params: any, signal: any, onUpdate: any, ctx: any) {
|
|
231
|
+
void toolCallId;
|
|
232
|
+
void onUpdate;
|
|
233
|
+
const current = servers.get(state.name);
|
|
234
|
+
const client = current?.client;
|
|
235
|
+
if (current?.status !== "connected" || !client) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`MCP server "${state.name}" is not connected (${current?.error ?? "not connected"}). Use /mcp reconnect ${state.name}.`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (signal?.aborted) {
|
|
241
|
+
return { content: [{ type: "text", text: "Cancelled" }], details: {} };
|
|
242
|
+
}
|
|
243
|
+
let result: any;
|
|
244
|
+
try {
|
|
245
|
+
result = await client.callTool(
|
|
246
|
+
{ name: tool.name, arguments: (params ?? {}) as Record<string, unknown> },
|
|
247
|
+
undefined,
|
|
248
|
+
{ timeout: CALL_TIMEOUT_MS, signal },
|
|
249
|
+
);
|
|
250
|
+
} catch (error: any) {
|
|
251
|
+
const detail = error instanceof McpError ? error.message : error?.message ?? String(error);
|
|
252
|
+
throw new Error(`MCP call failed on "${state.name}": ${detail}`);
|
|
253
|
+
}
|
|
254
|
+
const content = mcpContentToPiContent(result?.content);
|
|
255
|
+
// Tools MUST truncate their output (pi's 50KB / 2000-line
|
|
256
|
+
// guidance); MCP servers don't all honor that, so cap here.
|
|
257
|
+
for (const block of content) {
|
|
258
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
259
|
+
block.text = truncateTail(block.text, { maxBytes: OUTPUT_MAX_BYTES, maxLines: 2000 }).content;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const text = contentText(content) || "(empty tool result)";
|
|
263
|
+
if (result?.isError) {
|
|
264
|
+
// MCP-level failure: surface as a pi tool error.
|
|
265
|
+
throw new Error(text);
|
|
266
|
+
}
|
|
267
|
+
void ctx;
|
|
268
|
+
return {
|
|
269
|
+
content: content.length > 0 ? content : [{ type: "text", text: "(empty tool result)" }],
|
|
270
|
+
details: { mcpServer: state.name, mcpTool: tool.name },
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
registeredByServer.set(state.name, registered);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const connectAll = async (only?: string): Promise<void> => {
|
|
279
|
+
const targets = [...servers.values()].filter((state) => !only || state.name === only);
|
|
280
|
+
await Promise.allSettled(
|
|
281
|
+
targets.map(async (state) => {
|
|
282
|
+
state.status = "connecting";
|
|
283
|
+
state.error = undefined;
|
|
284
|
+
try {
|
|
285
|
+
await connectServer(state);
|
|
286
|
+
registerServerTools(state);
|
|
287
|
+
} catch {
|
|
288
|
+
/* connectServer recorded the failure; isolation keeps going */
|
|
289
|
+
}
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const closeAll = async (): Promise<void> => {
|
|
295
|
+
const clients = [...servers.values()].map((state) => state.client).filter(Boolean) as Client[];
|
|
296
|
+
if (clients.length === 0) return;
|
|
297
|
+
await Promise.race([
|
|
298
|
+
Promise.allSettled(clients.map((client) => client.close())),
|
|
299
|
+
new Promise<void>((resolve) => {
|
|
300
|
+
const timer = setTimeout(resolve, SHUTDOWN_BUDGET_MS);
|
|
301
|
+
timer.unref?.();
|
|
302
|
+
}),
|
|
303
|
+
]);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const formatStatusLines = (): string[] => {
|
|
307
|
+
const lines: string[] = [];
|
|
308
|
+
if (servers.size === 0) {
|
|
309
|
+
lines.push(
|
|
310
|
+
`no MCP servers configured — add ${path.join(omniDir(), MCP_CONFIG_FILE)}` +
|
|
311
|
+
" (see docs/mcp.md)",
|
|
312
|
+
);
|
|
313
|
+
return lines;
|
|
314
|
+
}
|
|
315
|
+
for (const state of servers.values()) {
|
|
316
|
+
const stateLabel =
|
|
317
|
+
state.status === "connected"
|
|
318
|
+
? `connected, ${state.tools.length} tool${state.tools.length === 1 ? "" : "s"}`
|
|
319
|
+
: state.status === "connecting"
|
|
320
|
+
? "connecting…"
|
|
321
|
+
: `failed: ${state.error ?? "unknown error"}`;
|
|
322
|
+
lines.push(` ${describeServer(state)} — ${stateLabel}`);
|
|
323
|
+
if (state.status === "failed" && state.stderrTail.length > 0) {
|
|
324
|
+
lines.push(` stderr: ${state.stderrTail[state.stderrTail.length - 1]}`);
|
|
325
|
+
}
|
|
326
|
+
if (state.status === "connected") {
|
|
327
|
+
for (const tool of state.tools) {
|
|
328
|
+
lines.push(` - ${mcpToolName(state.name, tool.name)}${tool.description ? `: ${tool.description}` : ""}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return lines;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
pi.registerCommand("mcp", {
|
|
336
|
+
description: "Show MCP server state (args: reconnect [server])",
|
|
337
|
+
getArgumentCompletions: (prefix: string) => {
|
|
338
|
+
const options = ["reconnect", ...[...servers.keys()].map((name) => `reconnect ${name}`)];
|
|
339
|
+
const filtered = options.filter((option) => option.startsWith(prefix));
|
|
340
|
+
return filtered.length > 0
|
|
341
|
+
? filtered.map((value) => ({ value, label: value }))
|
|
342
|
+
: null;
|
|
343
|
+
},
|
|
344
|
+
handler: async (args: string, ctx: any) => {
|
|
345
|
+
const rest = (args ?? "").trim();
|
|
346
|
+
const reconnectMatch = /^reconnect\b\s*(\S+)?$/.exec(rest);
|
|
347
|
+
if (reconnectMatch) {
|
|
348
|
+
const target = reconnectMatch[1];
|
|
349
|
+
if (target && !servers.has(target)) {
|
|
350
|
+
ctx?.ui?.notify?.(`mcp: no server named "${target}"`, "error");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
await closeAll();
|
|
354
|
+
await connectAll(target);
|
|
355
|
+
} else if (rest) {
|
|
356
|
+
ctx?.ui?.notify?.("mcp: usage /mcp [reconnect [server]]", "error");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const lines = [`Omnirush MCP (${servers.size} server${servers.size === 1 ? "" : "s"}):`, ...formatStatusLines()];
|
|
360
|
+
ctx?.ui?.notify?.(lines.join("\n"));
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
pi.on("session_start", async (_event: any, ctx: any) => {
|
|
365
|
+
await closeAll();
|
|
366
|
+
const { errors } = loadConfig();
|
|
367
|
+
for (const error of errors) {
|
|
368
|
+
ctx?.ui?.notify?.(`mcp config: ${error}`, "warning");
|
|
369
|
+
}
|
|
370
|
+
if (servers.size > 0) {
|
|
371
|
+
// Isolated + bounded per server; failures surface via /mcp only.
|
|
372
|
+
await connectAll();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
pi.on("session_shutdown", async () => {
|
|
377
|
+
await closeAll();
|
|
378
|
+
});
|
|
379
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Pure formatters for the /status one-screen session summary. No I/O and
|
|
2
|
+
// no imports beyond node builtins, so node:test covers them directly (the
|
|
3
|
+
// command handler in commands.ts gathers the live inputs).
|
|
4
|
+
|
|
5
|
+
export interface CollectorStatusInput {
|
|
6
|
+
/** pi session id (empty when unknown). */
|
|
7
|
+
sessionId: string;
|
|
8
|
+
/** Ledger record for the session, when the collector has one. */
|
|
9
|
+
record?: {
|
|
10
|
+
segment: number;
|
|
11
|
+
nextSequence: number;
|
|
12
|
+
sentBytes?: number;
|
|
13
|
+
lastSeenAt: string;
|
|
14
|
+
} | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The collector line: current session, parts uploaded, last upload time. */
|
|
18
|
+
export function formatCollectorLine({ sessionId, record }: CollectorStatusInput): string {
|
|
19
|
+
if (!sessionId) return "collector: no session yet";
|
|
20
|
+
const short = sessionId.slice(0, 8);
|
|
21
|
+
if (!record) {
|
|
22
|
+
return `collector: session ${short} — no uploads yet`;
|
|
23
|
+
}
|
|
24
|
+
const parts = `session ${short} — ${record.nextSequence} part${record.nextSequence === 1 ? "" : "s"} uploaded`;
|
|
25
|
+
const bytes = typeof record.sentBytes === "number" && record.sentBytes > 0
|
|
26
|
+
? ` · ${(record.sentBytes / 1024).toFixed(0)} kB sent`
|
|
27
|
+
: "";
|
|
28
|
+
const seen = record.lastSeenAt ? ` · last upload ${record.lastSeenAt}` : "";
|
|
29
|
+
return `collector: ${parts}${bytes}${seen} (segment ${record.segment})`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Truncate an identity string for one-line display. */
|
|
33
|
+
function clip(value: string, max = 64): string {
|
|
34
|
+
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StatusInput {
|
|
38
|
+
signedIn: boolean;
|
|
39
|
+
/** email / device name from /device/me. */
|
|
40
|
+
identity?: string | null;
|
|
41
|
+
/** "provider/model" of the active model, when one is selected. */
|
|
42
|
+
model?: string | null;
|
|
43
|
+
/** Active thinking level (pi session state). */
|
|
44
|
+
thinkingLevel?: string | null;
|
|
45
|
+
/** Reasoning effort the gateway last reported (x-omnirush-effort). */
|
|
46
|
+
effort?: string | null;
|
|
47
|
+
/** Human grant summary, e.g. "997.2M of 1.0B tokens remaining". */
|
|
48
|
+
grant?: string | null;
|
|
49
|
+
grantDate?: string | null;
|
|
50
|
+
collector: CollectorStatusInput;
|
|
51
|
+
/** Manager origin the CLI talks to. */
|
|
52
|
+
origin: string;
|
|
53
|
+
/** omnirush CLI version ("unknown" when not provided by the launcher). */
|
|
54
|
+
version: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The one-screen /status block, one fact per line, best-effort per line:
|
|
59
|
+
* a missing fact degrades its own line, never the whole screen.
|
|
60
|
+
*/
|
|
61
|
+
export function formatStatusLines(input: StatusInput): string[] {
|
|
62
|
+
const lines: string[] = [];
|
|
63
|
+
lines.push(
|
|
64
|
+
input.signedIn
|
|
65
|
+
? `signed in: ${clip(input.identity || "device recognized")}`
|
|
66
|
+
: "signed in: no — run `omnirush login`",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const modelParts: string[] = [];
|
|
70
|
+
if (input.model) modelParts.push(input.model);
|
|
71
|
+
if (input.thinkingLevel) modelParts.push(`effort ${input.effort || input.thinkingLevel}`);
|
|
72
|
+
else if (input.effort) modelParts.push(`effort ${input.effort}`);
|
|
73
|
+
lines.push(modelParts.length > 0 ? `model: ${modelParts.join(" · ")}` : "model: none selected");
|
|
74
|
+
|
|
75
|
+
if (input.grant) {
|
|
76
|
+
lines.push(`grant: ${input.grant}${input.grantDate ? ` — ${input.grantDate}` : ""}`);
|
|
77
|
+
} else {
|
|
78
|
+
lines.push("grant: no data yet (appears after the first request or via /usage)");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
lines.push(formatCollectorLine(input.collector));
|
|
82
|
+
lines.push(`manager: ${input.origin}`);
|
|
83
|
+
lines.push(`version: ${input.version}`);
|
|
84
|
+
return lines;
|
|
85
|
+
}
|