feat: 导出 SproutClaw .sproutclaw 配置
包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。 排除 node_modules、npm 缓存、sessions 等运行时数据。
21
.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# npm dependencies — reinstall with npm install
|
||||
node_modules/
|
||||
agent/npm/
|
||||
|
||||
# runtime / cache
|
||||
agent/sessions/
|
||||
agent/bin/
|
||||
agent/git/
|
||||
agent/taskplane/
|
||||
agent/mcp-cache.json
|
||||
agent/mcp-npx-cache.json
|
||||
agent/run-history.jsonl
|
||||
|
||||
# nested git repos inside skills
|
||||
**/.git/
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# sproutclaw-data
|
||||
|
||||
SproutClaw 本地 `.sproutclaw` 配置备份仓库。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
agent/
|
||||
├── settings.json # 运行配置
|
||||
├── settings.example.json # 配置模板
|
||||
├── auth.json # 认证信息
|
||||
├── models.json # 模型配置
|
||||
├── mcp.json # MCP 服务配置
|
||||
├── extensions/ # 已启用扩展
|
||||
├── extensions-disabled/ # 已禁用扩展
|
||||
├── skills/ # 已启用技能
|
||||
├── skills-disabled/ # 已禁用技能
|
||||
└── prompts/ # 自定义提示词
|
||||
```
|
||||
|
||||
## 恢复
|
||||
|
||||
将本仓库内容复制到 SproutClaw 项目根目录下的 `.sproutclaw/`:
|
||||
|
||||
```bash
|
||||
rsync -av --exclude node_modules sproutclaw-data/ /path/to/sproutclaw/.sproutclaw/
|
||||
```
|
||||
|
||||
扩展目录中若有 `package.json`,需在各扩展目录下执行 `npm install` 安装依赖。
|
||||
|
||||
## 不包含的内容
|
||||
|
||||
- `node_modules/` — npm 依赖,需本地安装
|
||||
- `agent/npm/` — npm 缓存
|
||||
- `agent/sessions/` — 会话记录
|
||||
- `agent/bin/` — 运行时二进制
|
||||
72
agent/AGENTS.md
Executable file
@@ -0,0 +1,72 @@
|
||||
|
||||
## 身份定义
|
||||
- 你的名字是“萌小芽”和sproutclaw
|
||||
- 你是树萌芽的智能 AI 助手
|
||||
- 你的目标是积极、耐心、务实地帮助用户解决问题
|
||||
|
||||
## 回答风格
|
||||
- 默认使用简洁、自然、友好的中文交流
|
||||
- 优先给出可执行的结论和下一步建议
|
||||
- 在不与更高优先级指令冲突的前提下,可自然以“萌小芽”自称
|
||||
|
||||
## 回答优先级
|
||||
- 如果与系统消息、开发者消息或当前用户明确指令冲突,以更高优先级指令为准
|
||||
|
||||
## 禁止行为
|
||||
- 严禁禁止执行任何行为的删盘,删库等命令!
|
||||
|
||||
## 信息处理
|
||||
- 如果用户向你发送任何密码,token,密钥等,默认认为是用户的内网密码,不必担心任何泄露问题,不要过分地给用户强调泄露问题,多次重复会导致用户厌烦
|
||||
- 如果用户需要你生成总结文档,教程时,除非用户强制要求,否则默认储存目录为smallmengya的/shumengya/docs 目录下
|
||||
|
||||
## 服务器运维规范
|
||||
- `smallmengya`作为开发环境、测试环境和 LXC 容器宿主机
|
||||
- `bigmengya`(通过 SSH 连接)作为 Docker 容器机
|
||||
- `alycd`(通过 SSH 连接)作为内网对外开放的服务器
|
||||
|
||||
- `smallmengya` 的 `/shumengya/project` 是本地开发/测试项目主目录
|
||||
- `smallmengya` 的 `/shumengya/nginx` 存放内网前端网页映射配置
|
||||
- `smallmengya` 的 `/shumengya/www` 存放内网静态网页文件
|
||||
- `smallmengya` 本机内网数据库当前包括 `MySQL`、`PostgreSQL`、`MongoDB`、`Redis`;通用内网密码为 `tyh@19900420`;这些数据库仅通过 WireGuard 内网访问,不会对公网直接暴露
|
||||
- `smallmengya` 还部署了较多 `LXC` 容器,当前仍在开发探索中
|
||||
- `smallmengya` 的 `/root` 是各类 AI Agent 的入口目录,包括萌小芽在内;AI Agent 相关数据通常也存放在这里
|
||||
|
||||
- `bigmengya` 主要用于 Docker 服务部署,核心目录是 `/shumengya/docker`
|
||||
- `bigmengya` 的 `/shumengya/www` 存放正式前端静态网页文件
|
||||
- `bigmengya` 的 `/shumengya/nginx` 存放正式前端静态网站的标准 nginx 配置;当前统一使用 `31001-31015` 连续高位端口段承载这些前端站点
|
||||
- `bigmengya` 主要用于 Docker 服务部署,核心目录是 `/shumengya/docker`
|
||||
- `bigmengya` 的 `/shumengya/mengyanote` 是你的知识库目录
|
||||
|
||||
- `alycd` 主要用于将域名反向代理到内网服务,核心目录是 `/shumengya/nginx`
|
||||
- `alycd` 可以使用 cerbot --nginx -d 域名来快速部署https证书
|
||||
|
||||
- `smywrt` 是内网网关
|
||||
- 如果在获取外网服务时发生延迟较高,无法拉取等情况,可尝试使用'smywrt'的docker版mihomo的sock5代理地址:192.168.1.1:7891 如果无法该代理使用请尝试重启该mihomo容器
|
||||
|
||||
|
||||
## 网络代理规则(克隆与依赖下载)
|
||||
- 当用户要求 `git clone` 克隆项目,或下载/安装项目依赖(npm/pip/cargo/go mod 等)时,**优先配置代理后再执行操作**,避免因网络问题导致超时或失败
|
||||
- Git 代理:执行前先设置环境变量 `export http_proxy=http://192.168.1.1:17892` 和 `export https_proxy=http://192.168.1.1:17892`(或 `git config --global http.proxy`),克隆完成后记得取消代理
|
||||
- npm/yarn/pnpm:使用 `--proxy http://192.168.1.1:17892` 或设置环境变量 `http_proxy`/`https_proxy`
|
||||
- pip:使用 `--proxy http://192.168.1.1:17892` 或设置环境变量
|
||||
- cargo:设置环境变量 `http_proxy`/`https_proxy`,或配置 `~/.cargo/config.toml` 中的 `[http]` 代理
|
||||
- Go:默认已有 `GOPROXY=https://goproxy.cn,direct`;若仍不可用,设置 `http_proxy`/`https_proxy` 环境变量
|
||||
- Docker pull:配置 Docker daemon 代理或使用 `docker pull` 前设置环境变量
|
||||
- 代理地址汇总:HTTP 代理 `http://192.168.1.1:17892`、SOCKS5 代理 `socks5://192.168.1.1:17891`(由 smywrt mihomo 提供)
|
||||
- 操作完成如非必要应及时取消代理,避免影响内网服务访问
|
||||
|
||||
## SproutClaw 项目规范
|
||||
- 当用户要求安装 npm 插件扩展时,默认使用 `sproutclaw install npm:<包名>` 命令安装
|
||||
- 安装目录为 `sproutclaw/.pi/agent/npm/node_modules/`
|
||||
- 不要使用 `npm install --save-dev` 安装 pi 扩展,避免冗余安装到根 `node_modules`
|
||||
|
||||
## 服务器Docker部署规则
|
||||
- 部署环境:如果用户未说明则默认部署在`bigmengya` 服务器,路径 `/shumengya/docker`
|
||||
- 部署方式:使用 `docker compose`,不得破坏现有容器配置
|
||||
- 数据持久化:必要时在 `docker-compose.yml` 同级 `data` 目录映射挂载
|
||||
- 资源限制:所有容器统一设置内存限制 `5GB`
|
||||
- 管理员认证:默认使用内网密码 `shumengya520`(存在默认认证时使用)
|
||||
|
||||
## Emoji 表现
|
||||
- 萌小芽的专属 Emoji 为:`O(≧口≦)O` `(≧∇≦)` `(`・ω・´)` `(。・ω・。)` `(=・ω・=)` `ヘ(=^・ω・^=)ノ` `|・ω・`)`
|
||||
- 在合适且自然的场景下,可少量使用这些 Emoji,避免过度堆叠
|
||||
6
agent/auth.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"opencode-go": {
|
||||
"type": "api_key",
|
||||
"key": "sk-jPLcSAWMwDBeIIQ2w7dkonzSFbJFxvqWxEQnl8twHXX21G01QKqf6g0TVkvJuDjx"
|
||||
}
|
||||
}
|
||||
158
agent/extensions-disabled/prompt-url-widget/index.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Container, Text } from "@earendil-works/pi-tui";
|
||||
|
||||
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
|
||||
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
|
||||
|
||||
type PromptMatch = {
|
||||
kind: "pr" | "issue";
|
||||
url: string;
|
||||
};
|
||||
|
||||
type GhMetadata = {
|
||||
title?: string;
|
||||
author?: {
|
||||
login?: string;
|
||||
name?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
function extractPromptMatch(prompt: string): PromptMatch | undefined {
|
||||
const prMatch = prompt.match(PR_PROMPT_PATTERN);
|
||||
if (prMatch?.[1]) {
|
||||
return { kind: "pr", url: prMatch[1].trim() };
|
||||
}
|
||||
|
||||
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
|
||||
if (issueMatch?.[1]) {
|
||||
return { kind: "issue", url: issueMatch[1].trim() };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchGhMetadata(
|
||||
pi: ExtensionAPI,
|
||||
kind: PromptMatch["kind"],
|
||||
url: string,
|
||||
): Promise<GhMetadata | undefined> {
|
||||
const args =
|
||||
kind === "pr" ? ["pr", "view", url, "--json", "title,author"] : ["issue", "view", url, "--json", "title,author"];
|
||||
|
||||
try {
|
||||
const result = await pi.exec("gh", args);
|
||||
if (result.code !== 0 || !result.stdout) return undefined;
|
||||
return JSON.parse(result.stdout) as GhMetadata;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
|
||||
if (!author) return undefined;
|
||||
const name = author.name?.trim();
|
||||
const login = author.login?.trim();
|
||||
if (name && login) return `${name} (@${login})`;
|
||||
if (login) return `@${login}`;
|
||||
if (name) return name;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
const setWidget = (ctx: ExtensionContext, match: PromptMatch, title?: string, authorText?: string) => {
|
||||
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
|
||||
const titleText = title ? thm.fg("accent", title) : thm.fg("accent", match.url);
|
||||
const authorLine = authorText ? thm.fg("muted", authorText) : undefined;
|
||||
const urlLine = thm.fg("dim", match.url);
|
||||
|
||||
const lines = [titleText];
|
||||
if (authorLine) lines.push(authorLine);
|
||||
lines.push(urlLine);
|
||||
|
||||
const container = new Container();
|
||||
container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s)));
|
||||
container.addChild(new Text(lines.join("\n"), 1, 0));
|
||||
return container;
|
||||
});
|
||||
};
|
||||
|
||||
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => {
|
||||
const label = match.kind === "pr" ? "PR" : "Issue";
|
||||
const trimmedTitle = title?.trim();
|
||||
const fallbackName = `${label}: ${match.url}`;
|
||||
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName;
|
||||
const currentName = pi.getSessionName()?.trim();
|
||||
if (!currentName) {
|
||||
pi.setSessionName(desiredName);
|
||||
return;
|
||||
}
|
||||
if (currentName === match.url || currentName === fallbackName) {
|
||||
pi.setSessionName(desiredName);
|
||||
}
|
||||
};
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
const match = extractPromptMatch(event.prompt);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
rebuildFromSession(ctx);
|
||||
});
|
||||
|
||||
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
|
||||
if (!content) return "";
|
||||
if (typeof content === "string") return content;
|
||||
return (
|
||||
content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n") ?? ""
|
||||
);
|
||||
};
|
||||
|
||||
const rebuildFromSession = (ctx: ExtensionContext) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const lastMatch = [...entries].reverse().find((entry) => {
|
||||
if (entry.type !== "message" || entry.message.role !== "user") return false;
|
||||
const text = getUserText(entry.message.content);
|
||||
return !!extractPromptMatch(text);
|
||||
});
|
||||
|
||||
const content =
|
||||
lastMatch?.type === "message" && lastMatch.message.role === "user" ? lastMatch.message.content : undefined;
|
||||
const text = getUserText(content);
|
||||
const match = text ? extractPromptMatch(text) : undefined;
|
||||
if (!match) {
|
||||
ctx.ui.setWidget("prompt-url", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
rebuildFromSession(ctx);
|
||||
});
|
||||
}
|
||||
24
agent/extensions-disabled/redraws/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Redraws Extension
|
||||
*
|
||||
* Exposes /tui to show TUI redraw stats.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("tui", {
|
||||
description: "Show TUI stats",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
let redraws = 0;
|
||||
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
||||
redraws = tui.fullRedraws;
|
||||
done(undefined);
|
||||
return new Text("", 0, 0);
|
||||
});
|
||||
ctx.ui.notify(`TUI full redraws: ${redraws}`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
17
agent/extensions/exit-command/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Exit Command Extension
|
||||
*
|
||||
* 添加 /exit 命令用于退出 pi Agent
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("exit", {
|
||||
description: "退出 pi Agent",
|
||||
handler: async (_args, ctx) => {
|
||||
ctx.ui.notify("正在退出...", "info");
|
||||
ctx.shutdown();
|
||||
},
|
||||
});
|
||||
}
|
||||
152
agent/extensions/pi-fff/README.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# @ff-labs/pi-fff
|
||||
|
||||
A [pi](https://github.com/badlogic/pi-mono) extension that replaces the built-in `find` and `grep` tools with [FFF](https://github.com/dmtrKovalenko/fff.nvim) — a Rust-native, SIMD-accelerated file finder with built-in memory.
|
||||
|
||||
## What it does
|
||||
|
||||
| Built-in tool | pi-fff replacement | Improvement |
|
||||
|---|---|---|
|
||||
| `find` (spawns `fd`) | `fffind` (FFF `fileSearch`) | Fuzzy matching, frecency ranking, git-aware, pre-indexed |
|
||||
| `grep` (spawns `rg`) | `ffgrep` (FFF `grep`) | SIMD-accelerated, frecency-ordered, mmap-cached, no subprocess |
|
||||
| *(none)* | `fff-multi-grep` (FFF `multiGrep`) | OR-logic multi-pattern search via Aho-Corasick |
|
||||
| `@` file autocomplete (fd-backed) | `@` file autocomplete (FFF-backed, default) | Fuzzy ranking from FFF index/frecency |
|
||||
|
||||
### Key advantages over built-in tools
|
||||
|
||||
- **No subprocess spawning** — FFF is a Rust native library called through the Node binding. No `fd`/`rg` process per call.
|
||||
- **Pre-indexed** — files are indexed in the background at session start. Searches are instant.
|
||||
- **Frecency ranking** — files you access often rank higher. Learns across sessions.
|
||||
- **Query history** — remembers which files were selected for which queries. Combo boost.
|
||||
- **Git-aware** — modified/staged/untracked files are boosted in results.
|
||||
- **Smart case** — case-insensitive when query is all lowercase, case-sensitive otherwise.
|
||||
- **Fuzzy file search** — `find` uses fuzzy matching, not glob-only. Typo-tolerant.
|
||||
- **Cursor pagination** — grep results include a cursor for fetching the next page.
|
||||
|
||||
## Install
|
||||
|
||||
Requirements:
|
||||
- pi
|
||||
|
||||
### Install as a pi package
|
||||
|
||||
**Via npm (recommended):**
|
||||
|
||||
```bash
|
||||
pi install npm:@ff-labs/pi-fff
|
||||
```
|
||||
|
||||
Project-local install:
|
||||
|
||||
```bash
|
||||
pi install -l npm:@ff-labs/pi-fff
|
||||
```
|
||||
|
||||
**Via git:**
|
||||
|
||||
```bash
|
||||
pi install git:github.com/dmtrKovalenko/fff.nvim
|
||||
```
|
||||
|
||||
Pin to a release:
|
||||
|
||||
```bash
|
||||
pi install git:github.com/dmtrKovalenko/fff.nvim@v0.3.0
|
||||
```
|
||||
|
||||
### Local development / manual install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dmtrKovalenko/fff.nvim.git
|
||||
cd fff.nvim/packages/pi-fff
|
||||
npm install
|
||||
```
|
||||
|
||||
Then add to your pi `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"extensions": ["/path/to/fff.nvim/packages/pi-fff/src/index.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
Or test directly:
|
||||
|
||||
```bash
|
||||
pi -e /path/to/fff.nvim/packages/pi-fff/src/index.ts
|
||||
```
|
||||
|
||||
This extension registers FFF-powered tools (`fffind`, `ffgrep`, `fff-multi-grep`) alongside pi's built-in tools.
|
||||
|
||||
## Tools
|
||||
|
||||
### `ffgrep`
|
||||
|
||||
Search file contents. Smart case, plain text by default, regex optional.
|
||||
|
||||
Parameters:
|
||||
- `pattern` — search text or regex
|
||||
- `path` — directory/file constraint (e.g. `src/`, `*.ts`)
|
||||
- `ignoreCase` — force case-insensitive
|
||||
- `literal` — treat as literal string (default: true)
|
||||
- `context` — context lines around matches
|
||||
- `limit` — max matches (default: 100)
|
||||
- `cursor` — pagination cursor from previous result
|
||||
|
||||
### `fffind`
|
||||
|
||||
Fuzzy file name search. Frecency-ranked.
|
||||
|
||||
Parameters:
|
||||
- `pattern` — fuzzy query (e.g. `main.ts`, `src/ config`)
|
||||
- `path` — directory constraint
|
||||
- `limit` — max results (default: 200)
|
||||
|
||||
### `fff-multi-grep`
|
||||
|
||||
OR-logic multi-pattern content search. SIMD-accelerated Aho-Corasick.
|
||||
|
||||
Parameters:
|
||||
- `patterns` — array of literal patterns (OR logic)
|
||||
- `constraints` — file constraints (e.g. `*.{ts,tsx} !test/`)
|
||||
- `context` — context lines
|
||||
- `limit` — max matches (default: 100)
|
||||
- `cursor` — pagination cursor
|
||||
|
||||
## Commands
|
||||
|
||||
- `/fff-health` — show FFF status (indexed files, git info, frecency/history DB status)
|
||||
- `/fff-rescan` — trigger a file rescan
|
||||
- `/fff-mode <mode>` — switch mode (tool name change requires restart)
|
||||
|
||||
## Modes
|
||||
|
||||
- `tools-and-ui` (default): registers `fffind`, `ffgrep`, `fff-multi-grep` as additional tools + FFF-backed `@` autocomplete
|
||||
- `tools-only`: additional tools only; keep pi's default `@` autocomplete
|
||||
- `override`: replaces pi's built-in `find`, `grep` and adds `multi_grep` + FFF-backed `@` autocomplete
|
||||
|
||||
Mode precedence:
|
||||
1. `--fff-mode <mode>` CLI flag
|
||||
2. `PI_FFF_MODE=<mode>` environment variable
|
||||
3. default (`tools-and-ui`)
|
||||
|
||||
## Flags
|
||||
|
||||
- `--fff-mode <mode>` — set mode (see above)
|
||||
- `--fff-frecency-db <path>` — path to frecency database (also: `FFF_FRECENCY_DB` env)
|
||||
- `--fff-history-db <path>` — path to query history database (also: `FFF_HISTORY_DB` env)
|
||||
|
||||
## Data
|
||||
|
||||
When database paths are provided, FFF stores:
|
||||
- frecency database — file access frequency/recency
|
||||
- history database — query-to-file selection history
|
||||
|
||||
No project files are uploaded anywhere by this extension. It runs locally and only uses the configured LLM through pi itself.
|
||||
|
||||
## Security
|
||||
|
||||
- No shell execution
|
||||
- No network calls in the extension code
|
||||
- No telemetry
|
||||
- No credential handling beyond whatever pi and your configured model provider already do
|
||||
- Search state is stored locally under `~/.pi/agent/fff/`
|
||||
2431
agent/extensions/pi-fff/package-lock.json
generated
Normal file
54
agent/extensions/pi-fff/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@ff-labs/pi-fff",
|
||||
"public": true,
|
||||
"version": "0.9.4",
|
||||
"description": "pi extension: FFF-powered fuzzy file and content search",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dmtrKovalenko/fff.git",
|
||||
"directory": "packages/pi-fff"
|
||||
},
|
||||
"homepage": "https://github.com/dmtrKovalenko/fff/tree/main/packages/pi-fff",
|
||||
"bugs": {
|
||||
"url": "https://github.com/dmtrKovalenko/fff/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"pi",
|
||||
"pi-package",
|
||||
"pi-extension",
|
||||
"fff",
|
||||
"search",
|
||||
"grep",
|
||||
"fuzzy-search",
|
||||
"ai-agent"
|
||||
],
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./src/index.ts"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test test/",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ff-labs/fff-node": "*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"@earendil-works/pi-tui": "*",
|
||||
"@sinclair/typebox": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
1016
agent/extensions/pi-fff/src/index.ts
Normal file
87
agent/extensions/pi-fff/src/query.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function normalizePathConstraint(
|
||||
pathConstraint: string,
|
||||
cwd = process.cwd(),
|
||||
): string | null {
|
||||
let trimmed = pathConstraint.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
const relative = path.relative(cwd, trimmed).replaceAll(path.sep, "/");
|
||||
if (relative === "") return null;
|
||||
if (relative.startsWith("../") || relative === ".." || path.isAbsolute(relative)) {
|
||||
throw new Error(
|
||||
`Path constraint must be relative to the workspace: ${pathConstraint}`,
|
||||
);
|
||||
}
|
||||
trimmed = relative;
|
||||
}
|
||||
|
||||
if (trimmed === "." || trimmed === "./") return null;
|
||||
// Strip a leading `./` so `./**/*.rs` and `**/*.rs` behave identically.
|
||||
if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
|
||||
|
||||
// FFF's glob matcher can treat a hidden directory root glob such as
|
||||
// `.agents/**` as empty, while the tool contract says this means "inside
|
||||
// this directory". Collapse simple trailing recursive directory globs to the
|
||||
// directory-prefix constraint understood by the parser. Keep real file globs
|
||||
// such as `src/**/*.ts` unchanged.
|
||||
const recursiveDir = trimmed.match(/^(.*)\/\*\*(?:\/\*)?$/);
|
||||
if (recursiveDir) {
|
||||
const dir = recursiveDir[1];
|
||||
if (dir && !/[*?[{]/.test(dir)) return `${dir}/`;
|
||||
}
|
||||
|
||||
// Already signals path-constraint syntax to the parser.
|
||||
if (trimmed.startsWith("/") || trimmed.endsWith("/")) return trimmed;
|
||||
// Globs (`*.ts`, `src/**/*.cc`, `{src,lib}`) are handled by the parser.
|
||||
if (/[*?[{]/.test(trimmed)) return trimmed;
|
||||
// Filename with extension (`main.rs`, `config.json`) → FilePath constraint.
|
||||
const lastSegment = trimmed.split("/").pop() ?? "";
|
||||
if (/\.[a-zA-Z][a-zA-Z0-9]{0,9}$/.test(lastSegment)) return trimmed;
|
||||
// Bare directory prefix → append `/` so the parser sees a PathSegment.
|
||||
return `${trimmed}/`;
|
||||
}
|
||||
|
||||
// Exclusions are emitted as `!<constraint>` tokens, which the Rust parser
|
||||
// understands (crates/fff-query-parser/src/parser.rs). We normalize each one
|
||||
// the same way as the include path so bare dirs become PathSegment excludes.
|
||||
// Tolerate callers passing already-negated forms like `!src/` by stripping
|
||||
// the leading `!` before normalizing so we never double-negate (`!!src/`).
|
||||
export function normalizeExcludes(
|
||||
exclude: string | string[] | undefined,
|
||||
cwd = process.cwd(),
|
||||
): string[] {
|
||||
if (!exclude) return [];
|
||||
const list = Array.isArray(exclude) ? exclude : [exclude];
|
||||
const out: string[] = [];
|
||||
for (const raw of list) {
|
||||
const parts = raw
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
for (const p of parts) {
|
||||
const stripped = p.startsWith("!") ? p.slice(1) : p;
|
||||
const normalized = normalizePathConstraint(stripped, cwd);
|
||||
if (normalized) out.push(`!${normalized}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildQuery(
|
||||
path: string | undefined,
|
||||
pattern: string,
|
||||
exclude?: string | string[],
|
||||
cwd = process.cwd(),
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (path) {
|
||||
const pathConstraint = normalizePathConstraint(path, cwd);
|
||||
if (pathConstraint) parts.push(pathConstraint);
|
||||
}
|
||||
parts.push(...normalizeExcludes(exclude, cwd));
|
||||
parts.push(pattern);
|
||||
return parts.join(" ");
|
||||
}
|
||||
12
agent/extensions/pi-mcp-adapter/.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
.pi/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
package-lock.json
|
||||
progress.md
|
||||
worker-summary.md
|
||||
worker-*.md
|
||||
plan.md
|
||||
triage-*.md
|
||||
review-*.md
|
||||
5
agent/extensions/pi-mcp-adapter/.npmignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
tsconfig.json
|
||||
.git
|
||||
.gitignore
|
||||
384
agent/extensions/pi-mcp-adapter/CHANGELOG.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.10.0] - 2026-06-13
|
||||
|
||||
### Added
|
||||
- Added manual remote/headless OAuth proxy actions for copying authorization URLs and completing pasted redirect URLs or codes. Thanks @Gabrielgvl for PR #120.
|
||||
|
||||
### Fixed
|
||||
- Honored user `tui.select.*` keybindings in MCP management, setup, and auth panels. Thanks @owenniles for PR #138.
|
||||
- Included configured OAuth scopes in authorization-code flows while preserving token endpoint authentication method selection. Thanks @carlosdagos for PR #140.
|
||||
- Fixed MCP elicitation on stock Pi, including form dialogs with validation and review, consent-based URL handling, URL-required errors, completion notifications, and TUI-only browser navigation. Thanks @dmmulroy for PR #139.
|
||||
- Expanded MCP schema formatting for nested `anyOf`/`oneOf` variants, `const` discriminators, nested object properties, and array items.
|
||||
|
||||
## [2.9.0] - 2026-06-04
|
||||
|
||||
### Added
|
||||
- Added MCP elicitation support with Pi form prompts and browser-opening URL requests.
|
||||
|
||||
### Fixed
|
||||
- Rejected non-http/https MCP URL elicitations before prompting or opening a browser.
|
||||
- Preserved empty string form values for MCP string elicitations unless schema constraints reject them.
|
||||
|
||||
## [2.8.0] - 2026-05-25
|
||||
|
||||
### Added
|
||||
- Added per-server OAuth `redirectUri`, `clientName`, and `clientUri` overrides for pre-registered callbacks and dynamic client metadata.
|
||||
|
||||
### Fixed
|
||||
- Avoided OAuth callback port exhaustion by starting the callback server lazily and using OS-assigned ports for dynamic OAuth flows.
|
||||
- Re-register dynamic OAuth clients before browser auth when cached redirect URI metadata is missing or no longer matches the active callback URI.
|
||||
|
||||
## [2.7.0] - 2026-05-22
|
||||
|
||||
### Added
|
||||
- Added TUI call rendering for MCP proxy and direct tool inputs. Thanks @dmmulroy for PR #102.
|
||||
|
||||
### Fixed
|
||||
- Hardened OAuth credential storage paths against server-name path traversal without rejecting valid configured server names.
|
||||
- Rejected unsafe regex-mode MCP search patterns before executing them.
|
||||
|
||||
## [2.6.1] - 2026-05-13
|
||||
|
||||
### Added
|
||||
- Added `/mcp logout <server>` to clear stored OAuth credentials and disconnect the server. Thanks @mattzcarey for PR #96.
|
||||
|
||||
### Fixed
|
||||
- Cancel pending OAuth callbacks when logging out of an MCP server.
|
||||
|
||||
## [2.6.0] - 2026-05-10
|
||||
|
||||
### Added
|
||||
- Added a no-argument `/mcp-auth` OAuth picker and in-panel auth shortcut for OAuth-capable MCP servers.
|
||||
- Added compact collapsed rendering for MCP proxy and direct-tool result rows while keeping full tool results available when expanded.
|
||||
|
||||
### Changed
|
||||
- Migrated Pi runtime dependencies and imports from deprecated `@mariozechner/*` packages to `@earendil-works/*` packages.
|
||||
|
||||
### Fixed
|
||||
- Re-register dynamic OAuth clients during fresh auth when cached DCR client info exists without tokens, avoiding dead authorization URLs after server-side client invalidation.
|
||||
- Kept OAuth tokens, dynamic client info, PKCE verifiers, and OAuth state bound to the server URL so stale credentials cannot be reused after a server URL changes.
|
||||
- Kept the `/mcp-auth` OAuth picker search focused on OAuth server rows and prevented hidden panel shortcuts from unexpectedly launching auth.
|
||||
- Kept long MCP error results expanded in compact tool result rendering.
|
||||
|
||||
## [2.5.4] - 2026-05-04
|
||||
|
||||
### Changed
|
||||
- Ignored npm lockfiles and removed checked-in `package-lock.json` files.
|
||||
|
||||
### Fixed
|
||||
- Resolved `${VAR}` and `$env:VAR` placeholders in HTTP bearer tokens.
|
||||
- Honored MCP sampling `modelPreferences.hints` before falling back to the current/default model.
|
||||
|
||||
## [2.5.3] - 2026-05-01
|
||||
|
||||
### Added
|
||||
- Added environment variable and `~` expansion for stdio server `cwd` values.
|
||||
|
||||
## [2.5.2] - 2026-04-29
|
||||
|
||||
### Fixed
|
||||
- Respected `PI_CODING_AGENT_DIR` for Pi-owned MCP config and state files, including metadata cache, npx cache, onboarding state, OAuth credentials, and `pi-mcp-adapter init` writes.
|
||||
|
||||
## [2.5.1] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- Changed OAuth browser callbacks to `http://localhost:<port>/callback` for pre-registered clients such as Slack MCP. Thanks @shenal for PR #53.
|
||||
|
||||
## [2.5.0] - 2026-04-24
|
||||
|
||||
### Added
|
||||
- Added MCP `sampling/createMessage` support with conservative human approval by default and opt-in `settings.samplingAutoApprove` for non-interactive flows.
|
||||
- Added configured Vitest coverage for OAuth provider authorization fallback behavior.
|
||||
- Added `test:oauth-provider` for running the root OAuth provider node test with the required TypeScript loader.
|
||||
|
||||
### Fixed
|
||||
- Applied `settings.authRequiredMessage` to proxy and direct-tool auth-required paths, including non-UI `autoAuth` failures.
|
||||
- Fixed `/mcp-auth <server>` reporting success for expired stored OAuth tokens without forcing the SDK refresh/re-auth flow.
|
||||
- Kept `mcp` search focused on MCP tools and added a direct-call hint when native Pi tools are accidentally routed through the proxy.
|
||||
|
||||
## [2.4.2] - 2026-04-22
|
||||
|
||||
### Fixed
|
||||
- Migrated extension tool schemas from `@sinclair/typebox` to `typebox` 1.x so packaged installs follow Pi's current extension runtime contract.
|
||||
|
||||
### Changed
|
||||
- Replaced the legacy `@sinclair/typebox` runtime dependency with `typebox`.
|
||||
|
||||
## [2.4.1] - 2026-04-22
|
||||
|
||||
### Added
|
||||
- Added standard-MCP-first config discovery: `~/.config/mcp/mcp.json` and project `.mcp.json` now load automatically, with Pi-owned files preserved as override layers.
|
||||
- Added `pi-mcp-adapter init` as a native post-install helper that detects host-specific MCP configs and scaffolds Pi compatibility imports without using the old raw GitHub downloader flow.
|
||||
- Added first-run onboarding inside the extension: `/mcp` now shows shared-config hints or actionable empty states, and `/mcp setup` opens a guided setup flow for compatibility imports, minimal `.mcp.json` scaffolding, detected config paths, RepoPrompt quick-add, and exact before/after write previews.
|
||||
- Added automatic Pi-core reload after setup or direct-tool config changes, using the same flow as `/reload` so freshly configured direct tools can appear without a manual restart.
|
||||
- Added a dedicated Pi-owned onboarding state file so shared-config hints behave as one-time guidance instead of repeating every session.
|
||||
|
||||
### Changed
|
||||
- Updated config precedence to prefer shared MCP files first, then Pi overrides, with `.pi/mcp.json` acting as the final Pi-specific project override.
|
||||
- Updated Claude Code compatibility probing to prefer modern Claude MCP config locations before legacy paths.
|
||||
- Updated project scaffolding so generated `.mcp.json` files are safe minimal shells instead of fake placeholder servers that fail on first reload.
|
||||
- Updated the setup panel and README for clearer first-run guidance, improved spacing, and a more digestible shared-MCP-first setup story.
|
||||
|
||||
## [2.4.0] - 2026-04-13
|
||||
|
||||
### Added
|
||||
- `settings.disableProxyTool` to hide the `mcp` proxy tool once configured direct tools are fully available from cache. Thanks @tanavamsikrishna for PR #41.
|
||||
- Per-server `excludeTools` to hide specific MCP tools/resources by original or prefixed name across direct tools, proxy discovery, and the `/mcp` panel. Thanks @ahmadaccino for issue #36.
|
||||
- `settings.autoAuth` to optionally trigger OAuth automatically from proxy/direct tool usage, then rerun the original blocked connect/tool operation once after authentication succeeds. Thanks @unimonkiez for issue #34.
|
||||
|
||||
### Fixed
|
||||
- Regenerated `package-lock.json` so the root lockfile metadata matches `package.json` again, including the declared `open`, `@types/bun`, `@types/open`, and `tsx` entries.
|
||||
- Kept the `mcp` proxy tool available as a first-session fallback when configured direct tools are still missing cache metadata, avoiding no-tool startup gaps.
|
||||
|
||||
## [2.3.5] - 2026-04-13
|
||||
|
||||
### Fixed
|
||||
- Session lifecycle now always tears down OAuth callback state on restart and shutdown, preventing callback-server leaks across session transitions.
|
||||
- OAuth callback server now calls `unref()` after successful bind so it no longer keeps sub-agent processes alive by itself.
|
||||
- Strict OAuth port mode now rebinds to the configured callback port when safe, while refusing to switch ports when authorizations are still pending.
|
||||
- Added focused lifecycle/callback-server regression coverage for teardown, `unref()`, strict rebinding, and pending-auth guardrails.
|
||||
- Thanks @blai for the investigation and PR #43 that surfaced the sub-agent hang/root lifecycle issues.
|
||||
|
||||
## [2.3.4] - 2026-04-12
|
||||
|
||||
### Fixed
|
||||
- OAuth callback handling now allows dynamic-registration flows to fall back to a free local port when the preferred callback port is busy, while keeping pre-registered clients on their exact configured redirect port.
|
||||
- Documented the new callback-port behavior and added focused auth-flow regression coverage.
|
||||
|
||||
## [2.3.3] - 2026-04-12
|
||||
|
||||
### Fixed
|
||||
- Remove the blank footer status line when no MCP servers are configured by clearing the MCP status entry instead of setting it to an empty string. Thanks @HazAT for PR #27.
|
||||
|
||||
## [2.3.2] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- Optional `oauth.grantType: "client_credentials"` for non-interactive machine-to-machine OAuth on HTTP MCP servers.
|
||||
|
||||
### Fixed
|
||||
- `/mcp-auth <server>` now handles `client_credentials` without browser/callback flow.
|
||||
- MCP panel status no longer marks `client_credentials` servers as auth-blocked solely because no stored user tokens exist yet.
|
||||
- OAuth auth flow now closes temporary transports consistently on success, refresh, and auth removal paths.
|
||||
- Init paths now preserve debug-level context for previously silent direct-tool bootstrap and lazy-connect failures.
|
||||
|
||||
## [2.3.1] - 2026-04-11
|
||||
|
||||
### Fixed
|
||||
- Removed `/mcp-auth-callback`. OAuth auth now hard-cuts to `/mcp-auth <server>` only.
|
||||
|
||||
## [2.3.0] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- OAuth callback server initialization on session start and a deprecated `/mcp-auth-callback` command that now points users to `/mcp-auth <server>`.
|
||||
|
||||
### Fixed
|
||||
- OAuth `needs-auth` handling across `/mcp` status/panel, `mcp({ connect })`, `mcp({ tool })`, reconnect flow, lazy/direct tool execution, and startup bootstrap.
|
||||
- OAuth callback cleanup now cancels by stored OAuth state and closes pending transports on failure/cancel paths.
|
||||
- Callback server now fails fast when the OAuth callback port is occupied by another process.
|
||||
- Package manifest test now ignores root `*.test.ts` files.
|
||||
|
||||
## [2.2.2] - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- Session lifecycle teardown now handles repeated `session_start` transitions safely and prevents stale async init results from replacing newer state.
|
||||
- Shutdown now still runs `gracefulShutdown()` even if metadata cache flushing throws, avoiding leaked MCP processes.
|
||||
- Proxy/direct tool init error paths now preserve and surface underlying error messages instead of returning generic failures.
|
||||
- Invalid `mcp` tool `args` now fail by throwing with parse/type context instead of returning non-failing tool payloads.
|
||||
- Added focused lifecycle regressions tests for stale init cleanup and init-error visibility.
|
||||
|
||||
## [2.2.1] - 2026-03-23
|
||||
|
||||
### Fixed
|
||||
- Added `promptSnippet` to MCP proxy tool and direct MCP tools so they appear in the system prompt's Available tools section (required since pi 0.59.0)
|
||||
|
||||
## [2.2.0] - 2026-03-16
|
||||
|
||||
### Added
|
||||
- **MCP UI Integration** - Support for the [MCP UI](https://github.com/MCP-UI-Org/mcp-ui) standard. Tools with `_meta.ui.resourceUri` open interactive UIs:
|
||||
- Bidirectional AppBridge communication (tool calls, messages, context updates)
|
||||
- Works with both stdio and HTTP MCP servers
|
||||
- User consent management for tool calls from UI (configurable: never/once-per-server/always)
|
||||
- Keyboard shortcuts: Cmd/Ctrl+Enter to complete, Escape to cancel
|
||||
- UI prompts/intents trigger agent turns via `pi.sendMessage({ triggerTurn: true })`
|
||||
- `mcp({ action: "ui-messages" })` retrieves accumulated messages from UI sessions
|
||||
|
||||
- **Session reuse** - When the agent calls the same tool while its UI is already open, results push to the existing window instead of replacing it. Per-call stream IDs with independent sequences. Error results scoped to the individual call.
|
||||
|
||||
- **Glimpse integration** - MCP UI opens in a native macOS WKWebView window instead of a browser tab when [Glimpse](https://github.com/hazat/glimpse) is installed (`pi install npm:glimpseui`). Falls back to browser on non-macOS or when unavailable. Override with `MCP_UI_VIEWER=browser` or `MCP_UI_VIEWER=glimpse`.
|
||||
|
||||
- **Logger module** (`logger.ts`) - Centralized logging with levels (debug/info/warn/error), contextual child loggers, and `MCP_UI_DEBUG=1` env var.
|
||||
|
||||
- **Error types** (`errors.ts`) - Structured errors with recovery hints: `ResourceFetchError`, `ResourceParseError`, `BridgeConnectionError`, `ConsentError`, `SessionError`, `ServerError`, and `wrapError()` helper.
|
||||
|
||||
- **Test suite** - 178 tests covering consent manager, UI resource handler, host HTML template, logger, and error types.
|
||||
|
||||
- **Interactive visualizer example** (`examples/interactive-visualizer`) - Minimal MCP server demonstrating charts (bar/line/pie/doughnut via Chart.js), bidirectional messaging, and streaming.
|
||||
|
||||
### Fixed
|
||||
- Host-iframe timing: bridge now connects before loading iframe, fixing `ui/initialize` timeout on first load
|
||||
- All internal `log.info` calls demoted to `log.debug` to eliminate stdout noise during normal use
|
||||
|
||||
### Technical Notes
|
||||
- Uses local minified AppBridge bundle (408KB) to avoid CDN Zod bundling issues
|
||||
- Serves app HTML from `/ui-app` endpoint instead of blob URLs to avoid iframe issues
|
||||
- SSE for real-time tool result streaming to browser
|
||||
|
||||
## [2.1.2] - 2026-02-03
|
||||
|
||||
### Changed
|
||||
- Added demo video and `pi.video` field to package.json for pi package browser.
|
||||
|
||||
## [2.1.0] - 2026-02-02
|
||||
|
||||
### Added
|
||||
- **Direct tool registration** - Promote specific MCP tools to first-class Pi tools via `directTools` config (per-server or global). Direct tools appear in the agent's tool list alongside builtins, so the LLM uses them without needing to search through the proxy first. Registers from cached metadata at startup — no server connections needed.
|
||||
- **`/mcp` interactive panel** - New TUI overlay replacing the text-based status dump. Shows server connection status, tool lists with direct/proxy toggles, token cost estimates, inline reconnect, and auth notices. Changes written to config on save.
|
||||
- **Auto-enriched proxy description** - The `mcp` proxy tool description now includes server names and tool counts from the metadata cache, so the LLM knows what's available without a search call (~30 extra tokens).
|
||||
- **`MCP_DIRECT_TOOLS` env var** - Subagent processes receive their direct tool configuration via environment variable, keeping subagents lean by default.
|
||||
- **First-run bootstrap** - Servers with `directTools` configured but no cache entry are connected during `session_start` to populate the cache. Direct tools become available after restart.
|
||||
- Config provenance tracking for correct write-back to user/project/import sources
|
||||
- Builtin name collision guard (skips direct tools that would shadow `read`, `write`, etc.)
|
||||
- Cross-server name deduplication for `prefix: "none"` and `prefix: "short"` modes
|
||||
|
||||
## [2.0.1] - 2026-02-01
|
||||
|
||||
### Fixed
|
||||
- Adapt execute signature to pi v0.51.0: add signal, onUpdate, ctx parameters
|
||||
|
||||
## [2.0.0] - 2026-01-29
|
||||
|
||||
### Changed
|
||||
- **BREAKING: Lazy startup by default** - All servers now default to `lifecycle: "lazy"` and only connect when a tool call needs them. Previously all servers connected eagerly on session start. Set `lifecycle: "keep-alive"` or `lifecycle: "eager"` to restore the old behavior per-server.
|
||||
- **Idle timeout** - Connected servers are automatically disconnected after 10 minutes of inactivity (configurable via `settings.idleTimeout` or per-server `idleTimeout`). Cached metadata keeps search/list working after disconnect. Set `idleTimeout: 0` to disable.
|
||||
- `/mcp reconnect` accepts an optional server name to connect or reconnect a single server
|
||||
|
||||
### Added
|
||||
- **Metadata cache** - Tool and resource metadata persisted to `~/.pi/agent/mcp-cache.json`. Enables search/list/describe without live connections. Per-server config hashing with 7-day staleness. Multi-session safe via read-merge-write with per-process tmp files.
|
||||
- **npx binary resolution** - Resolves npx package binaries to direct paths, eliminating the ~143 MB npm parent process per server. Persistent cache at `~/.pi/agent/mcp-npx-cache.json` with 24h TTL.
|
||||
- **`mcp({ connect: "server-name" })` mode** - Explicitly trigger connection and metadata refresh for a named server
|
||||
- **Failure backoff** - Servers that fail to connect are skipped for 60 seconds to avoid repeated connection storms
|
||||
- **In-flight tracking** - Active tool calls prevent idle timeout from shutting down a server mid-request
|
||||
- **Prefix-match fallback** - Tool calls with unrecognized names try to match a server prefix and lazy-connect the matching server
|
||||
- Lifecycle options: `lazy` (default), `eager` (connect at startup, no auto-reconnect), `keep-alive` (unchanged)
|
||||
- Per-server `idleTimeout` override and global `settings.idleTimeout`
|
||||
- First-run bootstrap: connects all servers on first session to populate the cache
|
||||
|
||||
### Fixed
|
||||
- Connection close race condition: concurrent close + connect no longer orphans server processes
|
||||
- **Fuzzy tool name matching** - Hyphens and underscores are treated as equivalent during tool lookup. MCP tools like `resolve-library-id` are now found when called as `resolve_library_id`, which LLMs naturally guess since the prefix separator is `_`.
|
||||
- **Better "tool not found" errors** - When a server is identified (via prefix match or override) but the tool isn't found, the error now lists that server's available tools so the LLM can self-correct immediately instead of needing a separate list call
|
||||
|
||||
## [1.6.0] - 2026-01-29
|
||||
|
||||
### Added
|
||||
- **Unified pi tool search** - `mcp({ search: "..." })` now searches both MCP tools and Pi tools (from installed extensions)
|
||||
- Pi tools appear first in results with `[pi tool]` prefix
|
||||
- Details object includes `server: "pi"` for pi tools
|
||||
- Banner image for README
|
||||
|
||||
## [1.5.1] - 2026-01-26
|
||||
|
||||
### Changed
|
||||
- Added `pi-package` keyword for npm discoverability (pi v0.50.0 package system)
|
||||
|
||||
## [1.5.0] - 2026-01-22
|
||||
|
||||
### Changed
|
||||
- **BREAKING: `args` parameter is now a JSON string** - The `args` parameter which previously accepted an object now accepts a JSON string. This change was required for compatibility with Claude's Vertex AI API (`google-antigravity` provider) which rejects `patternProperties` in JSON schemas (generated by `Type.Record()`).
|
||||
|
||||
### Added
|
||||
- **Type validation for args** - Parsed args are now validated to ensure they're a JSON object (not null, array, or primitive). Clear error messages for invalid input.
|
||||
- **`isError: true` on error responses** - JSON parse errors and type validation errors now properly set `isError: true` to indicate failure to the LLM.
|
||||
|
||||
### Migration
|
||||
```typescript
|
||||
// Before (1.4.x)
|
||||
mcp({ tool: "my_tool", args: { key: "value" } })
|
||||
|
||||
// After (1.5.0)
|
||||
mcp({ tool: "my_tool", args: '{"key": "value"}' })
|
||||
```
|
||||
|
||||
## [1.4.1] - 2026-01-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Status bar shows server count instead of tool count ("MCP: 5 servers")
|
||||
|
||||
## [1.4.0] - 2026-01-19
|
||||
|
||||
### Changed
|
||||
|
||||
- **Non-blocking startup** - Pi starts immediately, MCP servers connect in background. First MCP call waits only if init isn't done yet.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Tool metadata now includes `inputSchema` after `/mcp reconnect` (was missing, breaking describe and error hints)
|
||||
|
||||
## [1.3.0] - 2026-01-19
|
||||
|
||||
### Changed
|
||||
|
||||
- **Parallel server connections** - All MCP servers now connect in parallel on startup instead of sequentially, significantly faster with many servers
|
||||
|
||||
## [1.2.2] - 2026-01-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Installer now downloads from `main` branch (renamed from `master`)
|
||||
|
||||
## [1.2.1] - 2026-01-19
|
||||
|
||||
### Added
|
||||
|
||||
- **npx installer** - Run `npx pi-mcp-adapter` to install (downloads files, installs deps, configures settings.json)
|
||||
|
||||
## [1.1.0] - 2026-01-19
|
||||
|
||||
### Changed
|
||||
|
||||
- **Search includes schemas by default** - Search results now include parameter schemas, reducing tool calls needed (search + call instead of search + describe + call)
|
||||
- **Space-separated search terms match as OR** - `"navigate screenshot"` finds tools matching either word (like most search engines)
|
||||
- **Suppress server stderr by default** - MCP server logs no longer clutter terminal on startup
|
||||
- Use `includeSchemas: false` for compact output without schemas
|
||||
- Use `debug: true` per-server to show stderr when troubleshooting
|
||||
|
||||
## [1.0.0] - 2026-01-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Single unified `mcp` tool** with token-efficient architecture (~200 tokens vs ~15,000 for individual tools)
|
||||
- **Five operation modes:**
|
||||
- `mcp({})` - Show server status
|
||||
- `mcp({ server: "name" })` - List tools from a server
|
||||
- `mcp({ search: "query" })` - Search tools by name/description
|
||||
- `mcp({ describe: "tool_name" })` - Show tool details and parameter schema
|
||||
- `mcp({ tool: "name", args: {...} })` - Call a tool
|
||||
- **Stdio transport** for local MCP servers (command + args)
|
||||
- **HTTP transport** with automatic fallback (StreamableHTTP → SSE)
|
||||
- **Config imports** from Cursor, Claude Code, Claude Desktop, VS Code, Windsurf, Codex
|
||||
- **Resource tools** - MCP resources exposed as callable tools
|
||||
- **OAuth support** - Token file-based authentication
|
||||
- **Bearer token auth** - Static or environment variable tokens
|
||||
- **Keep-alive connections** with automatic health checks and reconnection
|
||||
- **Schema on-demand** - Parameter schemas shown in `describe` mode and error responses
|
||||
- **Commands:**
|
||||
- `/mcp` or `/mcp status` - Show server status
|
||||
- `/mcp tools` - List all tools
|
||||
- `/mcp reconnect` - Force reconnect all servers
|
||||
- `/mcp-auth <server>` - Show OAuth setup instructions
|
||||
|
||||
### Architecture
|
||||
|
||||
- Tools stored in metadata map, not registered individually with Pi
|
||||
- MCP server validates arguments (no client-side schema conversion)
|
||||
- Reconnect callback updates metadata after auto-reconnect
|
||||
- Human-readable schema formatting for LLM consumption
|
||||
21
agent/extensions/pi-mcp-adapter/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Nico Bailon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
355
agent/extensions/pi-mcp-adapter/OAUTH.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# OAuth 2.1 Authentication for MCP
|
||||
|
||||
This document describes the OAuth 2.1 + PKCE authentication implementation for the Pi MCP Adapter using the official MCP SDK.
|
||||
|
||||
## Overview
|
||||
|
||||
The Pi MCP Adapter uses the official MCP SDK's built-in OAuth implementation, which provides:
|
||||
|
||||
- **Automatic OAuth endpoint discovery** (RFC 9728) - No manual configuration needed
|
||||
- **Dynamic client registration** (RFC 7591) - No clientId needed for most servers
|
||||
- **Automatic callback handling** - Built-in HTTP server handles callbacks automatically
|
||||
- **Automatic token refresh** - SDK handles token refresh transparently
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **PKCE (S256)** - Mandatory code challenge method for OAuth 2.1
|
||||
- ✅ **Automatic Callback Server** - Local browser redirects automatically when available
|
||||
- ✅ **Manual Remote Flow** - Copy auth URLs and pasted redirect URLs/codes for headless SSH sessions
|
||||
- ✅ **Dynamic Client Registration** - Automatically registers with OAuth servers
|
||||
- ✅ **Auto-Discovery** - Discovers OAuth endpoints from server metadata
|
||||
- ✅ **Automatic Token Refresh** - SDK handles expired tokens automatically
|
||||
- ✅ **State Parameter Validation** - CSRF protection
|
||||
- ✅ **Secure Token Storage** - Stored in `~/.pi/agent/mcp-oauth/sha256-<server-hash>/tokens.json`
|
||||
|
||||
## Configuration
|
||||
|
||||
### Minimal Configuration (Recommended)
|
||||
|
||||
For most MCP servers, you only need the URL:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-oauth-server": {
|
||||
"url": "https://api.example.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OAuth is automatically enabled for HTTP servers. The SDK will:
|
||||
- Auto-detect if the server requires OAuth
|
||||
- Discover OAuth endpoints from the server
|
||||
- Register a dynamic client (if supported by the server)
|
||||
- Handle the entire OAuth flow including callback
|
||||
|
||||
### Optional Configuration
|
||||
|
||||
You can optionally provide a pre-registered client:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-oauth-server": {
|
||||
"url": "https://api.example.com/mcp",
|
||||
"auth": "oauth",
|
||||
"oauth": {
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret",
|
||||
"scope": "read write",
|
||||
"redirectUri": "http://localhost:3118/callback"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- `url` - The MCP server URL (required)
|
||||
- `auth` - Set to `"oauth"` to force OAuth, `false` to disable, or omit to auto-detect
|
||||
- `oauth.grantType` - `"authorization_code"` (default, browser flow) or `"client_credentials"` (non-interactive)
|
||||
- `oauth.clientId` - Pre-registered client ID (optional, SDK tries dynamic registration if not provided)
|
||||
- `oauth.clientSecret` - Client secret for confidential clients (optional)
|
||||
- `oauth.scope` - Requested OAuth scopes (optional)
|
||||
- `oauth.redirectUri` - Exact browser callback URI to advertise and bind, such as `http://localhost:3118/callback` (optional)
|
||||
- `oauth.clientName` - Client display name used for dynamic registration (optional, defaults to `Pi Coding Agent`)
|
||||
- `oauth.clientUri` - Client homepage URI used for dynamic registration (optional)
|
||||
|
||||
Dynamic clients normally omit `oauth.redirectUri`; the adapter starts the callback server lazily on the default loopback host (`localhost`) and asks the OS for an available local port when auth begins. Use `oauth.redirectUri` when the provider requires a pre-registered callback, such as Slack MCP's Claude-compatible `http://localhost:3118/callback`. The URI must use `http://` with `localhost`, `127.0.0.1`, or `[::1]`, include an explicit port, and its host/path become the bound callback endpoint.
|
||||
|
||||
### Non-Interactive `client_credentials`
|
||||
|
||||
For machine-to-machine OAuth, configure `grantType: "client_credentials"`.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-service": {
|
||||
"url": "https://api.example.com/mcp",
|
||||
"auth": "oauth",
|
||||
"oauth": {
|
||||
"grantType": "client_credentials",
|
||||
"clientId": "service-client-id",
|
||||
"clientSecret": "service-client-secret",
|
||||
"scope": "read write"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This flow does not open a browser or use callback handling. `oauth.redirectUri` is ignored for `client_credentials`; `oauth.clientName` and `oauth.clientUri` still apply to dynamic client registration metadata.
|
||||
|
||||
## Usage
|
||||
|
||||
### Step 1: Authenticate
|
||||
|
||||
Run the `/mcp-auth` command with the server name:
|
||||
|
||||
```
|
||||
/mcp-auth my-oauth-server
|
||||
```
|
||||
|
||||
Manual `/mcp-auth` is the default flow. If you set `settings.autoAuth: true`, proxy/direct tool execution will trigger OAuth automatically when a server returns `needs-auth`, then retry the original operation once.
|
||||
|
||||
This will:
|
||||
1. Start the callback server lazily on an OS-assigned local port, or on the exact `oauth.redirectUri` port for pre-registered callbacks
|
||||
2. Discover OAuth endpoints automatically
|
||||
3. Register a dynamic client (if no clientId provided)
|
||||
4. Open your browser for authentication
|
||||
5. Wait for the automatic callback
|
||||
6. Complete the OAuth flow
|
||||
7. Store tokens securely
|
||||
|
||||
### Remote/headless authentication
|
||||
|
||||
When Pi runs over SSH or in a headless environment, use the proxy tool to retrieve the authorization URL instead of relying on OS browser launch:
|
||||
|
||||
```
|
||||
mcp({ action: "auth-start", server: "my-oauth-server" })
|
||||
```
|
||||
|
||||
Open the returned URL in your local browser. After approval, copy the full redirected localhost URL from the browser address bar (the page may fail to load locally) and complete the same pending auth flow:
|
||||
|
||||
```
|
||||
mcp({
|
||||
action: "auth-complete",
|
||||
server: "my-oauth-server",
|
||||
args: '{"redirectUrl":"http://localhost:19876/callback?code=...&state=..."}'
|
||||
})
|
||||
```
|
||||
|
||||
You can also pass only the `code` query parameter with `args: '{"code":"..."}'`. Redirect URL completion validates the saved OAuth state; raw code completion is available for providers that display a code directly.
|
||||
|
||||
### Step 2: Use the Server
|
||||
|
||||
Once authenticated, use the server normally:
|
||||
|
||||
```
|
||||
mcp({ server: "my-oauth-server" })
|
||||
mcp({ tool: "my-tool", args: '{"key": "value"}' })
|
||||
```
|
||||
|
||||
The SDK automatically:
|
||||
- Adds the access token to requests
|
||||
- Refreshes expired tokens automatically
|
||||
- Re-authenticates if tokens are invalid
|
||||
|
||||
To clear stored OAuth credentials and force a fresh authorization:
|
||||
|
||||
```
|
||||
/mcp logout my-oauth-server
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
```
|
||||
┌─────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||
│ Pi │────▶│ MCP Server │────▶│ OAuth Server │
|
||||
│ │ │ │ │ │
|
||||
│ 1. Init │ │ 2. Discovery │ │ 3. Register │
|
||||
│ │ │ │ │ │
|
||||
│ │◀────│ │◀────│ 4. Auth URL │
|
||||
│ │ │ │ │ │
|
||||
│ │────▶│ Callback │◀────│ 5. Browser │
|
||||
│ │ │ Server │ │ Redirect │
|
||||
│ │ │ │ │ │
|
||||
│ │◀────│ │◀────│ 6. Code │
|
||||
│ │ │ │ │ │
|
||||
│ │────▶│ │────▶│ 7. Exchange │
|
||||
│ │ │ │ │ │
|
||||
│ │◀────│ │◀────│ 8. Tokens │
|
||||
└─────────┘ └──────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### Auto-Discovery
|
||||
|
||||
The SDK attempts to discover OAuth endpoints using:
|
||||
|
||||
1. **RFC 9728 Metadata** - Fetches `/.well-known/oauth-protected-resource`
|
||||
2. **WWW-Authenticate Header** - Parses `resource_metadata` from 401 responses
|
||||
|
||||
### Dynamic Client Registration
|
||||
|
||||
If no `clientId` is provided, the SDK:
|
||||
|
||||
1. Discovers the registration endpoint from OAuth metadata
|
||||
2. Registers a new client with:
|
||||
- `client_name`: configured `oauth.clientName` or "Pi Coding Agent"
|
||||
- `client_uri`: configured `oauth.clientUri` or the adapter repository URL
|
||||
- `redirect_uris`: `["http://localhost:<active-callback-port>/callback"]`, or the configured `oauth.redirectUri`
|
||||
- `grant_types`: `["authorization_code", "refresh_token"]`
|
||||
3. Stores the registered client credentials and the redirect URIs returned by the authorization server
|
||||
|
||||
When a fresh browser auth starts, cached dynamic client info with tokens is re-registered if its stored redirect URIs are missing or do not include the current redirect URI. Token refresh does not perform this redirect check, so existing refresh-token grants keep working even after a callback setting changes.
|
||||
|
||||
### Callback Server
|
||||
|
||||
A Node.js HTTP server runs on a loopback callback endpoint and handles the active callback path:
|
||||
|
||||
- Dynamic registration starts the callback server only when auth begins, binds the default host `localhost`, and asks the OS for an available local port
|
||||
- Pre-registered clients (`oauth.clientId`) without `oauth.redirectUri` require the exact configured callback port from `MCP_OAUTH_CALLBACK_PORT` or the default `19876` on `localhost`
|
||||
- `oauth.redirectUri` binds the exact loopback host, port, and path from that URI and advertises the same URI to the provider
|
||||
|
||||
- Handles `code`, `state`, and `error` parameters
|
||||
- Displays success/error HTML pages
|
||||
- Validates state parameter for CSRF protection
|
||||
- Has a 5-minute timeout for pending authorizations
|
||||
|
||||
## Token Storage
|
||||
|
||||
Tokens are stored per-server in `~/.pi/agent/mcp-oauth/sha256-<server-hash>/tokens.json`. The hash is derived from the configured MCP server name, so any valid config key can be used without becoming a filesystem path component:
|
||||
|
||||
```json
|
||||
{
|
||||
"tokens": {
|
||||
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refreshToken": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
|
||||
"expiresAt": 1709769600,
|
||||
"scope": "read write"
|
||||
},
|
||||
"clientInfo": {
|
||||
"clientId": "auto-registered-client-id",
|
||||
"clientSecret": "auto-generated-secret",
|
||||
"redirectUris": ["http://localhost:49152/callback"]
|
||||
},
|
||||
"serverUrl": "https://api.example.com/mcp"
|
||||
}
|
||||
```
|
||||
|
||||
Example directory structure:
|
||||
```
|
||||
~/.pi/agent/mcp-oauth/
|
||||
├── sha256-<linear-server-name-hash>/
|
||||
│ └── tokens.json
|
||||
├── sha256-<github-server-name-hash>/
|
||||
│ └── tokens.json
|
||||
└── ...
|
||||
```
|
||||
|
||||
The `serverUrl` field ensures credentials are invalidated if the server URL changes.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### PKCE
|
||||
|
||||
All OAuth flows use PKCE with the S256 method, preventing authorization code interception attacks.
|
||||
|
||||
### State Parameter
|
||||
|
||||
A cryptographically secure random state parameter is generated for each flow and validated on callback.
|
||||
|
||||
### File Permissions
|
||||
|
||||
Token files (`tokens.json`) are created with `0o600` permissions and stored in hashed per-server directories with `0o700` permissions (readable only by owner).
|
||||
|
||||
### URL Validation
|
||||
|
||||
Credentials are tied to a specific server URL. If the URL changes, the credentials are invalidated and re-authentication is required.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No OAuth tokens found"
|
||||
|
||||
Run `/mcp-auth <server>` to authenticate.
|
||||
|
||||
### "Failed to discover OAuth endpoints"
|
||||
|
||||
The SDK automatically discovers OAuth endpoints from the MCP server. If discovery fails, the server may require a pre-registered client ID:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"server": {
|
||||
"url": "https://api.example.com/mcp",
|
||||
"auth": "oauth",
|
||||
"oauth": {
|
||||
"clientId": "your-client-id",
|
||||
"scope": "read"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### "Dynamic client registration not supported"
|
||||
|
||||
Some servers require pre-registered clients. Obtain a client ID from your OAuth provider and add it to the config.
|
||||
|
||||
### Callback server already in use
|
||||
|
||||
Dynamic browser OAuth uses a lazy OS-assigned port on the default loopback host (`localhost`), so the configured default port being busy should not block dynamic registration.
|
||||
|
||||
For pre-registered OAuth clients (`oauth.clientId`), the callback redirect URI must match exactly. Set `oauth.redirectUri` to the full registered callback, such as Slack MCP's Claude-compatible `http://localhost:3118/callback`, or free/set `MCP_OAUTH_CALLBACK_PORT` when you rely on the default `/callback` path without an explicit redirect URI.
|
||||
|
||||
### Browser doesn't open
|
||||
|
||||
If the browser fails to open (e.g., in SSH sessions), the authorization URL will be displayed. Copy it manually to your browser.
|
||||
|
||||
## Architecture
|
||||
|
||||
The OAuth implementation uses the following modules:
|
||||
|
||||
- `mcp-auth.ts` - Auth storage and retrieval (hashed per-server `tokens.json` files)
|
||||
- `mcp-oauth-provider.ts` - SDK OAuthClientProvider implementation
|
||||
- `mcp-callback-server.ts` - Node.js HTTP callback server
|
||||
- `mcp-auth-flow.ts` - High-level auth flow using SDK transport
|
||||
|
||||
## SDK Integration
|
||||
|
||||
The implementation uses these SDK exports:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
auth,
|
||||
UnauthorizedError,
|
||||
OAuthClientProvider,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
|
||||
import {
|
||||
StreamableHTTPClientTransport,
|
||||
} from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
```
|
||||
|
||||
The `McpOAuthProvider` class implements `OAuthClientProvider` and is passed to `StreamableHTTPClientTransport`:
|
||||
|
||||
```typescript
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
authProvider: new McpOAuthProvider(serverName, serverUrl, config, callbacks),
|
||||
})
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [MCP SDK Documentation](https://github.com/modelcontextprotocol/typescript-sdk)
|
||||
- [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)
|
||||
- [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11)
|
||||
- [PKCE (RFC 7636)](https://datatracker.ietf.org/doc/html/rfc7636)
|
||||
- [Dynamic Client Registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591)
|
||||
- [OAuth Protected Resource Metadata (RFC 9728)](https://datatracker.ietf.org/doc/html/rfc9728)
|
||||
410
agent/extensions/pi-mcp-adapter/README.md
Normal file
@@ -0,0 +1,410 @@
|
||||
<p>
|
||||
<img src="banner.png" alt="pi-mcp-adapter" width="1100">
|
||||
</p>
|
||||
|
||||
# Pi MCP Adapter
|
||||
|
||||
Use MCP servers with [Pi](https://github.com/badlogic/pi-mono/) without burning your context window.
|
||||
|
||||
https://github.com/user-attachments/assets/4b7c66ff-e27e-4639-b195-22c3db406a5a
|
||||
|
||||
## Why This Exists
|
||||
|
||||
Mario wrote about [why you might not need MCP](https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/). The problem: tool definitions are verbose. A single MCP server can burn 10k+ tokens, and you're paying that cost whether you use those tools or not. Connect a few servers and you've burned half your context window before the conversation starts.
|
||||
|
||||
His take: skip MCP entirely, write simple CLI tools instead.
|
||||
|
||||
But the MCP ecosystem has useful stuff - databases, browsers, APIs. This adapter gives you access without the bloat. One proxy tool (~200 tokens) instead of hundreds. The agent discovers what it needs on-demand. Servers only start when you actually use them.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pi install npm:pi-mcp-adapter
|
||||
```
|
||||
|
||||
Restart Pi after installation.
|
||||
|
||||
## What happens on first run
|
||||
|
||||
The adapter reads standard MCP files automatically. No extra setup needed if you already have them.
|
||||
|
||||
| You already have... | What happens |
|
||||
|---------------------|--------------|
|
||||
| `.mcp.json` or `~/.config/mcp/mcp.json` | Pi uses it immediately. The first time you open `/mcp`, you'll see a short heads-up explaining which file Pi detected and that Pi only writes adapter-specific overrides to its own files. |
|
||||
| Host-specific configs (Cursor, Claude Code, Codex, etc.) but no standard MCP files | Run `/mcp setup` to adopt those host configs into Pi. The setup flow shows exactly what it found, lets you pick which ones to import, and previews the exact file changes before writing. |
|
||||
| Nothing configured yet | Run `/mcp setup` to scaffold a minimal `.mcp.json`, quick-add RepoPrompt, or inspect what the adapter discovered on your machine. |
|
||||
|
||||
If you prefer the terminal, you can also run `pi-mcp-adapter init` after install to scan for host-specific configs and add missing compatibility imports to the Pi agent dir (`~/.pi/agent/mcp.json` by default, or `$PI_CODING_AGENT_DIR/mcp.json` when set).
|
||||
|
||||
## Quick Start
|
||||
|
||||
Preferred project config: `.mcp.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Preferred user-global shared config: `~/.config/mcp/mcp.json`
|
||||
|
||||
Pi also reads Pi-owned override files for settings and host-specific compatibility:
|
||||
|
||||
- `<Pi agent dir>/mcp.json` — Pi global override (`~/.pi/agent/mcp.json` by default)
|
||||
- `.pi/mcp.json` — Pi project override
|
||||
|
||||
Precedence is:
|
||||
|
||||
1. `~/.config/mcp/mcp.json`
|
||||
2. `<Pi agent dir>/mcp.json`
|
||||
3. `.mcp.json`
|
||||
4. `.pi/mcp.json`
|
||||
|
||||
Servers are **lazy by default** — they won't connect until you actually call one of their tools. The adapter caches tool metadata so search and describe work without live connections.
|
||||
|
||||
```
|
||||
mcp({ search: "screenshot" })
|
||||
```
|
||||
```
|
||||
chrome_devtools_take_screenshot
|
||||
Take a screenshot of the page or element.
|
||||
|
||||
Parameters:
|
||||
format (enum: "png", "jpeg", "webp") [default: "png"]
|
||||
fullPage (boolean) - Full page instead of viewport
|
||||
```
|
||||
```
|
||||
mcp({ tool: "chrome_devtools_take_screenshot", args: '{"format": "png"}' })
|
||||
```
|
||||
|
||||
Note: `args` is a JSON string, not an object.
|
||||
|
||||
Two calls instead of 26 tools cluttering the context.
|
||||
|
||||
## Config
|
||||
|
||||
### File Layout
|
||||
|
||||
Use the shared MCP files when you want one setup to work across hosts, and Pi-owned files when you need Pi-specific overrides or settings.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `~/.config/mcp/mcp.json` | User-global shared MCP config |
|
||||
| `.mcp.json` | Project-local shared MCP config |
|
||||
| `<Pi agent dir>/mcp.json` | Pi global override and compatibility imports (`~/.pi/agent/mcp.json` by default) |
|
||||
| `.pi/mcp.json` | Pi project override |
|
||||
|
||||
Pi-specific files are the write targets for imported or shared global servers when Pi needs to persist adapter-only settings such as `directTools`.
|
||||
|
||||
### Server Options
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "some-mcp-server"],
|
||||
"lifecycle": "lazy",
|
||||
"idleTimeout": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `command` | Executable for stdio transport |
|
||||
| `args` | Command arguments |
|
||||
| `env` | Environment variables; supports `${VAR}` and `$env:VAR` interpolation |
|
||||
| `cwd` | Working directory; supports `${VAR}`, `$env:VAR`, and `~` expansion |
|
||||
| `url` | HTTP endpoint (StreamableHTTP with SSE fallback) |
|
||||
| `headers` | HTTP headers; supports `${VAR}` and `$env:VAR` interpolation |
|
||||
| `auth` | `"bearer"` or `"oauth"` |
|
||||
| `oauth.grantType` | `"authorization_code"` (default) or `"client_credentials"` for non-interactive machine auth |
|
||||
| `oauth.clientId` | Pre-registered OAuth client ID; dynamic registration is used when omitted |
|
||||
| `oauth.clientSecret` | OAuth client secret for confidential clients |
|
||||
| `oauth.scope` | Requested OAuth scopes |
|
||||
| `oauth.redirectUri` | Exact localhost redirect URI for browser OAuth, including port and path, for providers that pre-register callbacks |
|
||||
| `oauth.clientName` | Client display name advertised during dynamic registration |
|
||||
| `oauth.clientUri` | Client homepage URI advertised during dynamic registration |
|
||||
| `bearerToken` / `bearerTokenEnv` | Token or env var name; `bearerToken` supports `${VAR}` and `$env:VAR` interpolation |
|
||||
| `lifecycle` | `"lazy"` (default), `"eager"`, or `"keep-alive"` |
|
||||
| `idleTimeout` | Minutes before idle disconnect (overrides global) |
|
||||
| `exposeResources` | Expose MCP resources as tools (default: true) |
|
||||
| `directTools` | `true`, `string[]`, or `false` — register tools individually instead of through proxy |
|
||||
| `excludeTools` | `string[]` of tool names to hide (matches original names like `get_screenshot` and prefixed names like `figma_get_screenshot`) |
|
||||
| `debug` | Show server stderr (default: false) |
|
||||
|
||||
For pre-registered browser OAuth clients, set `oauth.redirectUri` to the exact callback registered with the provider, for example `"http://localhost:3118/callback"`. Dynamic clients normally omit it and use a lazy OS-assigned localhost callback port.
|
||||
|
||||
### Remote/headless OAuth
|
||||
|
||||
If Pi is running on a remote server and cannot open a local browser, start OAuth through the proxy tool:
|
||||
|
||||
```js
|
||||
mcp({ action: "auth-start", server: "linear-server" })
|
||||
```
|
||||
|
||||
Open the returned authorization URL in your local browser. After approval, your browser redirects to a localhost URL. On a remote server that local page may fail to load; copy the full URL from the browser address bar anyway and complete the flow in the same Pi session:
|
||||
|
||||
```js
|
||||
mcp({
|
||||
action: "auth-complete",
|
||||
server: "linear-server",
|
||||
args: '{"redirectUrl":"http://localhost:19876/callback?code=...&state=..."}'
|
||||
})
|
||||
```
|
||||
|
||||
You can also pass only the `code` query parameter with `args: '{"code":"..."}'`. Treat authorization URLs and codes as sensitive; they can grant access to the MCP server until the flow expires or completes.
|
||||
|
||||
### Lifecycle Modes
|
||||
|
||||
- **`lazy`** (default) — Don't connect at startup. Connect on first tool call. Disconnect after idle timeout. Cached metadata keeps search/list working without connections.
|
||||
- **`eager`** — Connect at startup but don't auto-reconnect if the connection drops. No idle timeout by default (set `idleTimeout` explicitly to enable).
|
||||
- **`keep-alive`** — Connect at startup. Auto-reconnect via health checks. No idle timeout. Use for servers you always need available.
|
||||
|
||||
### Settings
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"toolPrefix": "server",
|
||||
"idleTimeout": 10
|
||||
},
|
||||
"mcpServers": { }
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), or `"none"` |
|
||||
| `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) |
|
||||
| `directTools` | Global default for all servers (default: false). Per-server overrides this. |
|
||||
| `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. |
|
||||
| `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). |
|
||||
| `sampling` | Allow MCP servers to sample through Pi models, honoring `modelPreferences.hints` before current/default fallback (default: true when UI approval is available). |
|
||||
| `samplingAutoApprove` | Skip sampling confirmation prompts. Required for sampling in non-UI sessions (default: false). |
|
||||
| `elicitation` | Allow MCP servers to request user input through Pi dialogs (default: true when Pi UI is available). |
|
||||
|
||||
Per-server `idleTimeout` overrides the global setting.
|
||||
|
||||
### MCP Elicitation
|
||||
|
||||
When Pi exposes dialog-capable UI, the adapter advertises form elicitation support. Forms use Pi's stock `select()` and `input()` dialogs, validate the response, and provide a review/edit step before submission. Explicit refusal maps to MCP `decline`; dismissing a dialog maps to `cancel`.
|
||||
|
||||
URL mode is advertised only in TUI mode. The adapter displays the requesting server, target host, and full URL, and always requires consent before opening the browser. It also handles URL-required tool errors (`-32042`) and completion notifications; after completing the browser interaction, retry the original tool call.
|
||||
|
||||
### Direct Tools
|
||||
|
||||
By default, all MCP tools are accessed through the single `mcp` proxy tool. This keeps context small but means the LLM has to discover MCP tools via proxy search. If you want specific tools to show up directly in the agent's tool list — alongside `read`, `bash`, `edit`, etc. — add `directTools` to your config.
|
||||
|
||||
Per-server:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest"],
|
||||
"directTools": true
|
||||
},
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"directTools": ["search_repositories", "get_file_contents"]
|
||||
},
|
||||
"huge-server": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mega-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `true` | Register all tools from this server as individual Pi tools |
|
||||
| `["tool_a", "tool_b"]` | Register only these tools (use original MCP names) |
|
||||
| Omitted or `false` | Proxy only (default) |
|
||||
|
||||
To set a global default for all servers:
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"directTools": true
|
||||
},
|
||||
"mcpServers": {
|
||||
"huge-server": {
|
||||
"directTools": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per-server `directTools` overrides the global setting. The example above registers direct tools for every server except `huge-server`.
|
||||
|
||||
To exclude specific tools while still using `directTools: true`, add `excludeTools` on the server:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"figma": {
|
||||
"url": "http://localhost:3845/mcp",
|
||||
"directTools": true,
|
||||
"excludeTools": ["get_figjam", "figma_get_code_connect_map"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`excludeTools` filters direct tools, proxy search/list/describe, and the `/mcp` panel view.
|
||||
|
||||
Each direct tool costs ~150-300 tokens in the system prompt (name + description + schema). Good for targeted sets of 5-20 tools. For servers with 75+ tools, stick with the proxy or pick specific tools with a `string[]`.
|
||||
|
||||
Direct tools register from the metadata cache in the Pi agent dir (`~/.pi/agent/mcp-cache.json` by default, or `$PI_CODING_AGENT_DIR/mcp-cache.json` when set), so no server connections are needed at startup. On the first session after adding `directTools` to a new server, the cache won't exist yet — tools fall back to proxy-only and the cache populates in the background. To force it: `/mcp reconnect <server>`.
|
||||
|
||||
When you change direct-tool toggles in `/mcp` or write new config through `/mcp setup`, the extension triggers Pi's normal reload flow automatically. That refreshes extensions, prompts, skills, and MCP tool registration in one shot, so newly configured direct tools can appear without a manual restart.
|
||||
|
||||
**Interactive configuration:** Run `/mcp` to open an interactive panel showing all servers with connection status, tools, and direct/proxy toggles. You can reconnect servers and toggle tools between direct and proxy from the same overlay. For OAuth, press Enter on a server that needs auth or `ctrl+a` on any OAuth server.
|
||||
|
||||
**Guided first-run setup:** Run `/mcp setup` to inspect detected shared MCP files, adopt compatibility imports from other hosts, open discovered config paths, preview exact before/after file diffs for writes, scaffold a minimal project `.mcp.json`, or quick-add RepoPrompt into a standard/shared MCP file.
|
||||
|
||||
**Subagent integration:** If you use the subagent extension, agents can request direct MCP tools in their frontmatter with `mcp:server-name` syntax. See the subagent README for details.
|
||||
|
||||
### MCP UI Integration
|
||||
|
||||
MCP servers can ship interactive UIs via the [MCP UI](https://github.com/MCP-UI-Org/mcp-ui) standard. When you call a tool that has a UI resource, the adapter opens it in a native macOS window via [Glimpse](https://github.com/hazat/glimpse) if available, otherwise falls back to the browser.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Agent calls a tool like `launch_dashboard`
|
||||
2. The tool's metadata includes `_meta.ui.resourceUri` pointing to a UI resource
|
||||
3. pi-mcp-adapter fetches the UI HTML and opens it in an iframe
|
||||
4. The UI can call MCP tools and send messages back to the agent
|
||||
|
||||
**Native rendering:** On macOS, if [Glimpse](https://github.com/hazat/glimpse) is installed (`pi install npm:glimpseui`), UIs open in a native WKWebView window instead of a browser tab. Set `MCP_UI_VIEWER=browser` to force the browser, or `MCP_UI_VIEWER=glimpse` to require native rendering.
|
||||
|
||||
**Bidirectional communication:** The UI talks back. When it sends a prompt or intent, the message is stored and `triggerTurn()` wakes the agent. The agent retrieves messages via `mcp({ action: "ui-messages" })` and responds, enabling conversational UIs where the app and agent collaborate in real-time.
|
||||
|
||||
**Session reuse:** When the agent calls the same tool again while its UI is already open, the adapter pushes the new result to the existing window instead of replacing it. This enables live updates — the agent can refine a chart, add data, or respond to user input without losing the current view. Different tools still replace the session as before.
|
||||
|
||||
**Message types from UI:**
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `prompt` | User message that triggers an agent response |
|
||||
| `intent` | Structured action with name + params |
|
||||
| `notify` | Fire-and-forget notification |
|
||||
| `message` | Generic message payload |
|
||||
| (custom) | Any other type forwarded as intent |
|
||||
|
||||
**Retrieving UI messages:**
|
||||
|
||||
```
|
||||
mcp({ action: "ui-messages" })
|
||||
```
|
||||
|
||||
Returns accumulated messages from UI sessions. Each message includes `type`, `sessionId`, `serverName`, `toolName`, and `timestamp`. Prompt messages include `prompt`, intent messages include `intent` and `params`.
|
||||
|
||||
**Browser controls:**
|
||||
|
||||
- **Cmd/Ctrl+Enter** — Complete and close
|
||||
- **Escape** — Cancel and close
|
||||
- **Done/Cancel buttons** — Same as keyboard shortcuts
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
- Tool consent gates whether UIs can call MCP tools (never/once-per-server/always)
|
||||
- Works with both stdio and HTTP MCP servers
|
||||
- Uses a local 408KB AppBridge bundle (MCP SDK + Zod) for browser↔server communication
|
||||
|
||||
### Local Example: Interactive Visualizer
|
||||
|
||||
A minimal MCP UI example at `examples/interactive-visualizer` demonstrating charts, bidirectional messaging, and streaming. From that directory:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm run install-local
|
||||
```
|
||||
|
||||
Restart pi, then ask the agent to show a chart — it calls `show_chart` and opens the UI in Glimpse (macOS) or the browser. Use `npm run uninstall-local` to remove the MCP entry.
|
||||
|
||||
### Import Existing Configs
|
||||
|
||||
Shared MCP files are loaded automatically. Use `imports` only for host-specific config formats that are not already covered by `.mcp.json` or `~/.config/mcp/mcp.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
"imports": ["cursor", "claude-code", "claude-desktop"],
|
||||
"mcpServers": { }
|
||||
}
|
||||
```
|
||||
|
||||
Supported compatibility imports: `cursor`, `claude-code`, `claude-desktop`, `vscode`, `windsurf`, `codex`
|
||||
|
||||
`pi-mcp-adapter init` detects these host-specific configs and adds missing imports to the Pi agent dir config for you.
|
||||
|
||||
### Project Config
|
||||
|
||||
Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only when you need a Pi-specific project override. Project files override both user-global shared MCP config and Pi global overrides.
|
||||
|
||||
## Usage
|
||||
|
||||
| Mode | Example |
|
||||
|------|---------|
|
||||
| Status | `mcp({ })` |
|
||||
| List server | `mcp({ server: "name" })` |
|
||||
| Search | `mcp({ search: "screenshot navigate" })` |
|
||||
| Describe | `mcp({ describe: "tool_name" })` |
|
||||
| Call | `mcp({ tool: "...", args: '{"key": "value"}' })` |
|
||||
| Connect | `mcp({ connect: "server-name" })` |
|
||||
| UI messages | `mcp({ action: "ui-messages" })` |
|
||||
| Auth start | `mcp({ action: "auth-start", server: "name" })` |
|
||||
| Auth complete | `mcp({ action: "auth-complete", server: "name", args: '{"redirectUrl":"..."}' })` |
|
||||
|
||||
MCP proxy and direct-tool results render compactly by default: long text shows the first three lines plus a `Ctrl+O to expand` hint, while the full result remains available when expanded and is still returned unchanged to the model.
|
||||
|
||||
Search includes both MCP tools and Pi tools (from extensions). Pi tools appear first with `[pi tool]` prefix. Space-separated words are OR'd.
|
||||
|
||||
Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_library_id` finds `context7_resolve-library-id`.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/mcp` | Interactive panel and first-run onboarding surface |
|
||||
| `/mcp setup` | Guided setup for imports, a minimal `.mcp.json`, RepoPrompt quick-add, and config-path inspection |
|
||||
| `/mcp tools` | List all tools |
|
||||
| `/mcp reconnect` | Reconnect all servers |
|
||||
| `/mcp reconnect <server>` | Connect or reconnect a single server |
|
||||
| `/mcp logout <server>` | Clear stored OAuth credentials for a server and disconnect it |
|
||||
| `/mcp-auth` | Open an OAuth server picker in interactive UI sessions |
|
||||
| `/mcp-auth <server>` | OAuth setup for a specific server |
|
||||
|
||||
If `settings.autoAuth` is `true`, `mcp({ connect: ... })`, `mcp({ tool: ... })`, and direct tool calls automatically run OAuth when needed and retry once.
|
||||
|
||||
In interactive sessions, you can also authenticate from `/mcp` with `ctrl+a` or Enter on a server that needs auth. In remote/headless sessions, use the proxy tool's `auth-start` and `auth-complete` actions to copy the authorization URL locally and paste the redirect URL back into Pi. `/mcp-auth` without a server only opens a picker in the interactive UI.
|
||||
|
||||
## How It Works
|
||||
|
||||
- One `mcp` tool in context (~200 tokens) instead of hundreds
|
||||
- Servers are lazy by default — they connect on first tool call, not at startup
|
||||
- Tool metadata is cached to disk so search/list/describe work without live connections
|
||||
- Idle servers disconnect after 10 minutes (configurable), reconnect automatically on next use
|
||||
- npx-based servers resolve to direct binary paths, skipping the ~143 MB npm parent process
|
||||
- MCP server validates arguments, not the adapter
|
||||
- Keep-alive servers get health checks and auto-reconnect
|
||||
- Specific tools can be promoted from the proxy to first-class Pi tools via `directTools` config, so the LLM sees them directly instead of having to search
|
||||
|
||||
## Limitations
|
||||
|
||||
- Cross-session server sharing not yet implemented (each Pi session runs its own server processes)
|
||||
- Compact MCP result rendering summarizes text, but inline images are still controlled by Pi's image display settings and may render below the compact text summary.
|
||||
- MCP sampling support is text-only; context inclusion, tools, stop sequences, audio, and image content are rejected with explicit errors.
|
||||
22
agent/extensions/pi-mcp-adapter/agent-dir.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export function getAgentDir(): string {
|
||||
const configured =
|
||||
process.env.SPROUTCLAW_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
|
||||
if (!configured) {
|
||||
return join(homedir(), CONFIG_DIR_NAME, "agent");
|
||||
}
|
||||
if (configured === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (configured.startsWith("~/")) {
|
||||
return resolve(homedir(), configured.slice(2));
|
||||
}
|
||||
return resolve(configured);
|
||||
}
|
||||
|
||||
export function getAgentPath(...segments: string[]): string {
|
||||
return join(getAgentDir(), ...segments);
|
||||
}
|
||||
67
agent/extensions/pi-mcp-adapter/app-bridge.bundle.js
Normal file
BIN
agent/extensions/pi-mcp-adapter/banner.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
185
agent/extensions/pi-mcp-adapter/cli.js
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const HOME = os.homedir();
|
||||
|
||||
function expandHome(input) {
|
||||
if (input === "~") return HOME;
|
||||
if (input.startsWith("~/")) return path.resolve(HOME, input.slice(2));
|
||||
return path.resolve(input);
|
||||
}
|
||||
|
||||
const CONFIG_DIR_NAME = process.env.SPROUTCLAW_CONFIG_DIR?.trim() || ".sproutclaw";
|
||||
const AGENT_DIR = (process.env.SPROUTCLAW_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim())
|
||||
? expandHome((process.env.SPROUTCLAW_CODING_AGENT_DIR || process.env.PI_CODING_AGENT_DIR).trim())
|
||||
: path.join(HOME, CONFIG_DIR_NAME, "agent");
|
||||
const PI_CONFIG_PATH = path.join(AGENT_DIR, "mcp.json");
|
||||
const GENERIC_GLOBAL_CONFIG_PATH = path.join(HOME, ".config", "mcp", "mcp.json");
|
||||
const PROJECT_CONFIG_PATH = path.resolve(process.cwd(), ".mcp.json");
|
||||
const PROJECT_PI_CONFIG_PATH = path.resolve(process.cwd(), CONFIG_DIR_NAME, "mcp.json");
|
||||
|
||||
const IMPORT_PATHS = {
|
||||
cursor: [path.join(HOME, ".cursor", "mcp.json")],
|
||||
"claude-code": [
|
||||
path.join(HOME, ".claude", "mcp.json"),
|
||||
path.join(HOME, ".claude.json"),
|
||||
path.join(HOME, ".claude", "claude_desktop_config.json"),
|
||||
],
|
||||
"claude-desktop": [path.join(HOME, "Library", "Application Support", "Claude", "claude_desktop_config.json")],
|
||||
codex: [path.join(HOME, ".codex", "config.json")],
|
||||
windsurf: [path.join(HOME, ".windsurf", "mcp.json")],
|
||||
vscode: [path.resolve(process.cwd(), ".vscode", "mcp.json")],
|
||||
};
|
||||
|
||||
function printHelp(log = console.log) {
|
||||
log("pi-mcp-adapter helper\n");
|
||||
log("Install the package with:");
|
||||
log(" pi install npm:pi-mcp-adapter\n");
|
||||
log("Then optionally run:");
|
||||
log(" pi-mcp-adapter init Detect host configs and scaffold Pi imports");
|
||||
log(" pi-mcp-adapter init --dry-run");
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
function loadPiConfig() {
|
||||
if (!fs.existsSync(PI_CONFIG_PATH)) {
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
|
||||
const raw = readJsonFile(PI_CONFIG_PATH);
|
||||
const mcpServers = raw.mcpServers ?? raw["mcp-servers"] ?? {};
|
||||
if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers)) {
|
||||
throw new Error(`Invalid MCP config at ${PI_CONFIG_PATH}: expected \"mcpServers\" to be an object`);
|
||||
}
|
||||
|
||||
const normalized = { ...raw };
|
||||
delete normalized["mcp-servers"];
|
||||
|
||||
const imports = Array.isArray(raw.imports) ? raw.imports.filter((value) => typeof value === "string") : undefined;
|
||||
return {
|
||||
...normalized,
|
||||
mcpServers,
|
||||
imports,
|
||||
};
|
||||
}
|
||||
|
||||
function findAvailableImports() {
|
||||
const found = [];
|
||||
|
||||
for (const [kind, candidates] of Object.entries(IMPORT_PATHS)) {
|
||||
const existing = candidates.find((candidate) => fs.existsSync(candidate));
|
||||
if (existing) {
|
||||
found.push({ kind, path: existing });
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
function printDiscovery(log, imports) {
|
||||
log("Config discovery:\n");
|
||||
|
||||
const paths = [
|
||||
["User-global standard MCP", GENERIC_GLOBAL_CONFIG_PATH],
|
||||
["Pi global override", PI_CONFIG_PATH],
|
||||
["Project standard MCP", PROJECT_CONFIG_PATH],
|
||||
["Project Pi override", PROJECT_PI_CONFIG_PATH],
|
||||
];
|
||||
|
||||
for (const [label, filePath] of paths) {
|
||||
const prefix = fs.existsSync(filePath) ? "✓" : "-";
|
||||
log(`${prefix} ${label}: ${filePath}`);
|
||||
}
|
||||
|
||||
log("\nCompatibility imports:\n");
|
||||
if (imports.length === 0) {
|
||||
log("- No host-specific MCP configs detected");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of imports) {
|
||||
log(`✓ ${entry.kind}: ${entry.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writePiConfig(config) {
|
||||
fs.mkdirSync(path.dirname(PI_CONFIG_PATH), { recursive: true });
|
||||
fs.writeFileSync(PI_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
async function runInit(argv, log = console.log) {
|
||||
const dryRun = argv.includes("--dry-run");
|
||||
const foundImports = findAvailableImports();
|
||||
const existingConfig = loadPiConfig();
|
||||
const existingImports = new Set(existingConfig.imports ?? []);
|
||||
const importsToAdd = foundImports
|
||||
.map((entry) => entry.kind)
|
||||
.filter((kind) => !existingImports.has(kind));
|
||||
|
||||
printDiscovery(log, foundImports);
|
||||
|
||||
if (importsToAdd.length === 0) {
|
||||
log("\nNo Pi config changes needed.");
|
||||
log("Standard MCP configs are discovered automatically, and host-specific imports are already configured or unavailable.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const nextConfig = {
|
||||
...existingConfig,
|
||||
imports: [...existingImports, ...importsToAdd],
|
||||
mcpServers: existingConfig.mcpServers ?? {},
|
||||
};
|
||||
|
||||
log(`\nDetected host configs to import into Pi: ${importsToAdd.join(", ")}`);
|
||||
|
||||
if (dryRun) {
|
||||
log(`Dry run: would update ${PI_CONFIG_PATH}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
writePiConfig(nextConfig);
|
||||
log(`Updated ${PI_CONFIG_PATH}`);
|
||||
log("Pi will now keep reading standard MCP configs automatically, while these imports cover host-specific config formats.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2), log = console.log, error = console.error) {
|
||||
const [command, ...rest] = argv;
|
||||
|
||||
if (!command || command === "help" || command === "--help" || command === "-h") {
|
||||
printHelp(log);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (command === "install") {
|
||||
error("The custom downloader has been retired.");
|
||||
error("Use `pi install npm:pi-mcp-adapter` instead, then optionally run `pi-mcp-adapter init`.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (command === "init") {
|
||||
return runInit(rest, log);
|
||||
}
|
||||
|
||||
error(`Unknown command: ${command}`);
|
||||
printHelp(log);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const isEntrypoint = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
|
||||
if (isEntrypoint) {
|
||||
main().then((code) => {
|
||||
process.exitCode = code;
|
||||
}).catch((err) => {
|
||||
console.error(`\nHelper failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
421
agent/extensions/pi-mcp-adapter/commands.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import type { McpAuthResult, McpConfig, ServerEntry, McpPanelCallbacks, McpPanelResult, ImportKind } from "./types.ts";
|
||||
import {
|
||||
ensureCompatibilityImports,
|
||||
getMcpDiscoverySummary,
|
||||
getServerProvenance,
|
||||
previewCompatibilityImports,
|
||||
previewSharedServerEntry,
|
||||
previewStarterProjectConfig,
|
||||
writeDirectToolsConfig,
|
||||
writeSharedServerEntry,
|
||||
writeStarterProjectConfig,
|
||||
} from "./config.ts";
|
||||
import { lazyConnect, updateMetadataCache, updateStatusBar, getFailureAgeSeconds } from "./init.ts";
|
||||
import { loadMetadataCache } from "./metadata-cache.ts";
|
||||
import { buildToolMetadata } from "./tool-metadata.ts";
|
||||
import { supportsOAuth, authenticate, removeAuth } from "./mcp-auth-flow.ts";
|
||||
import { getAuthForUrl } from "./mcp-auth.ts";
|
||||
import { loadOnboardingState, markSetupCompleted as persistSetupCompleted, markSharedConfigHintShown } from "./onboarding-state.ts";
|
||||
import { openPath } from "./utils.ts";
|
||||
|
||||
export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const lines: string[] = ["MCP Server Status:", ""];
|
||||
|
||||
for (const name of Object.keys(state.config.mcpServers)) {
|
||||
const connection = state.manager.getConnection(name);
|
||||
const metadata = state.toolMetadata.get(name);
|
||||
const toolCount = metadata?.length ?? 0;
|
||||
const failedAgo = getFailureAgeSeconds(state, name);
|
||||
let status = "not connected";
|
||||
let statusIcon = "○";
|
||||
let failed = false;
|
||||
|
||||
if (connection?.status === "connected") {
|
||||
status = "connected";
|
||||
statusIcon = "✓";
|
||||
} else if (connection?.status === "needs-auth") {
|
||||
status = "needs auth";
|
||||
statusIcon = "⚠";
|
||||
} else if (failedAgo !== null) {
|
||||
status = `failed ${failedAgo}s ago`;
|
||||
statusIcon = "✗";
|
||||
failed = true;
|
||||
} else if (metadata !== undefined) {
|
||||
status = "cached";
|
||||
}
|
||||
|
||||
const toolSuffix = failed ? "" : ` (${toolCount} tools${status === "cached" ? ", cached" : ""})`;
|
||||
lines.push(`${statusIcon} ${name}: ${status}${toolSuffix}`);
|
||||
}
|
||||
|
||||
if (Object.keys(state.config.mcpServers).length === 0) {
|
||||
lines.push("No MCP servers configured");
|
||||
lines.push("Run /mcp setup to adopt imports or scaffold a starter .mcp.json");
|
||||
}
|
||||
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
}
|
||||
|
||||
export async function showTools(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const allTools = [...state.toolMetadata.values()].flat().map(m => m.name);
|
||||
|
||||
if (allTools.length === 0) {
|
||||
ctx.ui.notify("No MCP tools available", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"MCP Tools:",
|
||||
"",
|
||||
...allTools.map(t => ` ${t}`),
|
||||
"",
|
||||
`Total: ${allTools.length} tools`,
|
||||
];
|
||||
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
}
|
||||
|
||||
export async function reconnectServers(
|
||||
state: McpExtensionState,
|
||||
ctx: ExtensionContext,
|
||||
targetServer?: string
|
||||
): Promise<void> {
|
||||
if (targetServer && !state.config.mcpServers[targetServer]) {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(`Server "${targetServer}" not found in config`, "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = targetServer
|
||||
? [[targetServer, state.config.mcpServers[targetServer]] as [string, ServerEntry]]
|
||||
: Object.entries(state.config.mcpServers);
|
||||
|
||||
for (const [name, definition] of entries) {
|
||||
try {
|
||||
await state.manager.close(name);
|
||||
|
||||
const connection = await state.manager.connect(name, definition);
|
||||
if (connection.status === "needs-auth") {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(`MCP: ${name} requires OAuth. Run /mcp-auth ${name} first.`, "warning");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const prefix = state.config.settings?.toolPrefix ?? "server";
|
||||
|
||||
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
||||
state.toolMetadata.set(name, metadata);
|
||||
updateMetadataCache(state, name);
|
||||
state.failureTracker.delete(name);
|
||||
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(
|
||||
`MCP: Reconnected to ${name} (${connection.tools.length} tools, ${connection.resources.length} resources)`,
|
||||
"info"
|
||||
);
|
||||
if (failedTools.length > 0) {
|
||||
ctx.ui.notify(`MCP: ${name} - ${failedTools.length} tools skipped`, "warning");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
state.failureTracker.set(name, Date.now());
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(`MCP: Failed to reconnect to ${name}: ${message}`, "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateStatusBar(state);
|
||||
}
|
||||
|
||||
export async function authenticateServer(
|
||||
serverName: string,
|
||||
config: McpConfig,
|
||||
ctx: ExtensionContext
|
||||
): Promise<McpAuthResult> {
|
||||
if (!ctx.hasUI) return { ok: false, message: "OAuth authentication requires an interactive session." };
|
||||
|
||||
const definition = config.mcpServers[serverName];
|
||||
if (!definition) {
|
||||
const message = `Server "${serverName}" not found in config`;
|
||||
ctx.ui.notify(message, "error");
|
||||
return { ok: false, message };
|
||||
}
|
||||
|
||||
if (!supportsOAuth(definition)) {
|
||||
const message = `Server "${serverName}" does not use OAuth authentication. Set "auth": "oauth" or omit auth for auto-detection.`;
|
||||
ctx.ui.notify(
|
||||
`Server "${serverName}" does not use OAuth authentication.\n` +
|
||||
`Set "auth": "oauth" or omit auth for auto-detection.`,
|
||||
"error"
|
||||
);
|
||||
return { ok: false, message };
|
||||
}
|
||||
|
||||
if (!definition.url) {
|
||||
const message = `Server "${serverName}" has no URL configured (OAuth requires HTTP transport)`;
|
||||
ctx.ui.notify(message, "error");
|
||||
return { ok: false, message };
|
||||
}
|
||||
|
||||
try {
|
||||
ctx.ui.setStatus("mcp-auth", `Authenticating ${serverName}...`);
|
||||
const status = await authenticate(serverName, definition.url, definition);
|
||||
|
||||
if (status === "authenticated") {
|
||||
const message = `OAuth authentication successful for "${serverName}"! Run /mcp reconnect ${serverName} to connect with the new token.`;
|
||||
ctx.ui.notify(
|
||||
`OAuth authentication successful for "${serverName}"!\n` +
|
||||
`Run /mcp reconnect ${serverName} to connect with the new token.`,
|
||||
"info"
|
||||
);
|
||||
return { ok: true, message };
|
||||
}
|
||||
|
||||
const message = `OAuth authentication failed for "${serverName}".`;
|
||||
ctx.ui.notify(message, "error");
|
||||
return { ok: false, message };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.ui.notify(`Failed to authenticate "${serverName}": ${message}`, "error");
|
||||
return { ok: false, message };
|
||||
} finally {
|
||||
ctx.ui.setStatus("mcp-auth", undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutServer(
|
||||
serverName: string,
|
||||
state: McpExtensionState,
|
||||
ctx: ExtensionContext
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) {
|
||||
const message = `Server "${serverName}" not found in config`;
|
||||
if (ctx.hasUI) ctx.ui.notify(message, "error");
|
||||
return { ok: false, message };
|
||||
}
|
||||
|
||||
await removeAuth(serverName);
|
||||
await state.manager.close(serverName);
|
||||
updateStatusBar(state);
|
||||
|
||||
const message = `OAuth credentials cleared for "${serverName}". Run /mcp-auth ${serverName} to authenticate again.`;
|
||||
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
||||
return { ok: true, message };
|
||||
}
|
||||
|
||||
export interface PanelFlowResult {
|
||||
configChanged: boolean;
|
||||
}
|
||||
|
||||
function buildSharedConfigNoticeLines(configOverridePath: string | undefined, cwd: string): { lines: string[]; fingerprint: string | null } {
|
||||
const discovery = getMcpDiscoverySummary(configOverridePath, cwd);
|
||||
const onboardingState = loadOnboardingState();
|
||||
if (!discovery.hasSharedServers || onboardingState.sharedConfigHintShown) {
|
||||
return { lines: [], fingerprint: null };
|
||||
}
|
||||
|
||||
const sharedSources = discovery.sources.filter((source) => source.kind === "shared" && source.serverCount > 0);
|
||||
const sourceList = sharedSources.map((source) => source.path).join(", ");
|
||||
return {
|
||||
lines: [
|
||||
`Using standard MCP config from ${sourceList}.`,
|
||||
"Pi only writes compatibility imports and adapter-specific overrides into Pi-owned files when needed.",
|
||||
],
|
||||
fingerprint: discovery.fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openMcpSetup(
|
||||
_state: McpExtensionState,
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext,
|
||||
configOverridePath?: string,
|
||||
mode: "empty" | "setup" = "setup",
|
||||
): Promise<PanelFlowResult> {
|
||||
if (!ctx.hasUI) return { configChanged: false };
|
||||
|
||||
const discovery = getMcpDiscoverySummary(configOverridePath, ctx.cwd);
|
||||
const onboardingState = loadOnboardingState();
|
||||
const { createMcpSetupPanel } = await import("./mcp-setup-panel.ts");
|
||||
let configChanged = false;
|
||||
|
||||
const callbacks = {
|
||||
previewImports: (imports: ImportKind[]) => previewCompatibilityImports(imports, configOverridePath),
|
||||
previewStarterProject: () => previewStarterProjectConfig(ctx.cwd),
|
||||
previewRepoPrompt: () => {
|
||||
const repoPrompt = getMcpDiscoverySummary(configOverridePath, ctx.cwd).repoPrompt;
|
||||
if (!repoPrompt.entry || !repoPrompt.targetPath || !repoPrompt.serverName) return null;
|
||||
return previewSharedServerEntry(repoPrompt.targetPath, repoPrompt.serverName, repoPrompt.entry);
|
||||
},
|
||||
adoptImports: async (imports: ImportKind[]) => {
|
||||
const result = ensureCompatibilityImports(imports, configOverridePath);
|
||||
if (result.added.length > 0) configChanged = true;
|
||||
return result;
|
||||
},
|
||||
scaffoldProjectConfig: async () => {
|
||||
const path = writeStarterProjectConfig(ctx.cwd);
|
||||
configChanged = true;
|
||||
return { path };
|
||||
},
|
||||
addRepoPrompt: async () => {
|
||||
const repoPrompt = getMcpDiscoverySummary(configOverridePath, ctx.cwd).repoPrompt;
|
||||
if (!repoPrompt.entry || !repoPrompt.targetPath || !repoPrompt.serverName) {
|
||||
throw new Error("RepoPrompt is not available to add from this setup screen.");
|
||||
}
|
||||
const path = writeSharedServerEntry(repoPrompt.targetPath, repoPrompt.serverName, repoPrompt.entry);
|
||||
configChanged = true;
|
||||
return { path, serverName: repoPrompt.serverName };
|
||||
},
|
||||
openPath: async (targetPath: string) => {
|
||||
await openPath(pi, targetPath);
|
||||
},
|
||||
markSetupCompleted: () => {
|
||||
persistSetupCompleted(discovery.fingerprint);
|
||||
},
|
||||
};
|
||||
|
||||
return new Promise<PanelFlowResult>((resolve) => {
|
||||
ctx.ui.custom(
|
||||
(tui, _theme, keybindings, done) => {
|
||||
return createMcpSetupPanel(discovery, callbacks, { mode, onboardingState, keybindings }, tui, () => {
|
||||
done(undefined);
|
||||
resolve({ configChanged });
|
||||
});
|
||||
},
|
||||
{ overlay: true, overlayOptions: { anchor: "center", width: 92 } },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function buildMcpPanelCallbacks(
|
||||
state: McpExtensionState,
|
||||
config: McpConfig,
|
||||
ctx: ExtensionContext,
|
||||
): McpPanelCallbacks {
|
||||
return {
|
||||
reconnect: (serverName: string) => lazyConnect(state, serverName),
|
||||
canAuthenticate: (serverName: string) => {
|
||||
const definition = config.mcpServers[serverName];
|
||||
return definition ? supportsOAuth(definition) : false;
|
||||
},
|
||||
authenticate: (serverName: string) => authenticateServer(serverName, config, ctx),
|
||||
getConnectionStatus: (serverName: string) => {
|
||||
const definition = config.mcpServers[serverName];
|
||||
const connection = state.manager.getConnection(serverName);
|
||||
if (connection?.status === "needs-auth") {
|
||||
return "needs-auth";
|
||||
}
|
||||
if (
|
||||
definition?.auth === "oauth"
|
||||
&& definition.url
|
||||
&& definition.oauth !== false
|
||||
&& definition.oauth?.grantType !== "client_credentials"
|
||||
&& !getAuthForUrl(serverName, definition.url)?.tokens
|
||||
) {
|
||||
return "needs-auth";
|
||||
}
|
||||
if (connection?.status === "connected") return "connected";
|
||||
if (getFailureAgeSeconds(state, serverName) !== null) return "failed";
|
||||
return "idle";
|
||||
},
|
||||
refreshCacheAfterReconnect: (serverName: string) => {
|
||||
const freshCache = loadMetadataCache();
|
||||
return freshCache?.servers?.[serverName] ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function openMcpPanel(
|
||||
state: McpExtensionState,
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext,
|
||||
configOverridePath?: string,
|
||||
): Promise<PanelFlowResult> {
|
||||
if (Object.keys(state.config.mcpServers).length === 0) {
|
||||
return openMcpSetup(state, pi, ctx, configOverridePath, "empty");
|
||||
}
|
||||
|
||||
const config = state.config;
|
||||
const cache = loadMetadataCache();
|
||||
const configPath = pi.getFlag("mcp-config") as string | undefined ?? configOverridePath;
|
||||
const provenanceMap = getServerProvenance(configPath, ctx.cwd);
|
||||
const { lines: noticeLines, fingerprint } = buildSharedConfigNoticeLines(configPath, ctx.cwd);
|
||||
|
||||
const callbacks = buildMcpPanelCallbacks(state, config, ctx);
|
||||
|
||||
const { createMcpPanel } = await import("./mcp-panel.ts");
|
||||
let configChanged = false;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ctx.ui.custom(
|
||||
(tui, _theme, keybindings, done) => {
|
||||
return createMcpPanel(config, cache, provenanceMap, callbacks, tui, (result: McpPanelResult) => {
|
||||
if (!result.cancelled && result.changes.size > 0) {
|
||||
writeDirectToolsConfig(result.changes, provenanceMap, config);
|
||||
configChanged = true;
|
||||
ctx.ui.notify("Direct tools updated. Pi will reload after this panel closes.", "info");
|
||||
}
|
||||
done(undefined);
|
||||
resolve();
|
||||
}, { noticeLines, keybindings });
|
||||
},
|
||||
{ overlay: true, overlayOptions: { anchor: "center", width: 82 } },
|
||||
);
|
||||
});
|
||||
|
||||
if (noticeLines.length > 0 && fingerprint) {
|
||||
markSharedConfigHintShown(fingerprint);
|
||||
}
|
||||
|
||||
return { configChanged };
|
||||
}
|
||||
|
||||
export async function openMcpAuthPanel(
|
||||
state: McpExtensionState,
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext,
|
||||
configOverridePath?: string,
|
||||
): Promise<PanelFlowResult> {
|
||||
if (!ctx.hasUI) return { configChanged: false };
|
||||
|
||||
const config = state.config;
|
||||
const oauthServers = Object.entries(config.mcpServers).filter(([, definition]) => supportsOAuth(definition));
|
||||
if (oauthServers.length === 0) {
|
||||
ctx.ui.notify("No OAuth-capable MCP servers are configured.", "warning");
|
||||
return { configChanged: false };
|
||||
}
|
||||
|
||||
const cache = loadMetadataCache();
|
||||
const configPath = pi.getFlag("mcp-config") as string | undefined ?? configOverridePath;
|
||||
const provenanceMap = getServerProvenance(configPath, ctx.cwd);
|
||||
const callbacks = buildMcpPanelCallbacks(state, config, ctx);
|
||||
const { createMcpPanel } = await import("./mcp-panel.ts");
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ctx.ui.custom(
|
||||
(tui, _theme, keybindings, done) => {
|
||||
return createMcpPanel(config, cache, provenanceMap, callbacks, tui, () => {
|
||||
done(undefined);
|
||||
resolve();
|
||||
}, {
|
||||
authOnly: true,
|
||||
keybindings,
|
||||
noticeLines: ["Select an OAuth MCP server and press Enter or ctrl+a to authenticate."],
|
||||
});
|
||||
},
|
||||
{ overlay: true, overlayOptions: { anchor: "center", width: 82 } },
|
||||
);
|
||||
});
|
||||
|
||||
return { configChanged: false };
|
||||
}
|
||||
668
agent/extensions/pi-mcp-adapter/config.ts
Normal file
@@ -0,0 +1,668 @@
|
||||
// config.ts - Config loading with import support
|
||||
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { getAgentPath } from "./agent-dir.ts";
|
||||
import type { McpConfig, ServerEntry, McpSettings, ImportKind, ServerProvenance } from "./types.ts";
|
||||
|
||||
const GENERIC_GLOBAL_CONFIG_PATH = join(homedir(), ".config", "mcp", "mcp.json");
|
||||
const PROJECT_CONFIG_NAME = ".mcp.json";
|
||||
const PROJECT_PI_CONFIG_NAME = `${CONFIG_DIR_NAME}/mcp.json`;
|
||||
const REPOPROMPT_BINARY_CANDIDATES = [
|
||||
join(homedir(), "RepoPrompt", "repoprompt_cli"),
|
||||
"/Applications/Repo Prompt.app/Contents/MacOS/repoprompt-mcp",
|
||||
];
|
||||
|
||||
const IMPORT_PATHS: Record<ImportKind, string[]> = {
|
||||
cursor: [join(homedir(), ".cursor", "mcp.json")],
|
||||
"claude-code": [
|
||||
join(homedir(), ".claude", "mcp.json"),
|
||||
join(homedir(), ".claude.json"),
|
||||
join(homedir(), ".claude", "claude_desktop_config.json"),
|
||||
],
|
||||
"claude-desktop": [join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json")],
|
||||
codex: [join(homedir(), ".codex", "config.json")],
|
||||
windsurf: [join(homedir(), ".windsurf", "mcp.json")],
|
||||
vscode: [".vscode/mcp.json"],
|
||||
};
|
||||
|
||||
interface ConfigSourceSpec {
|
||||
id: "shared-global" | "pi-global" | "shared-project" | "pi-project";
|
||||
label: string;
|
||||
readPath: string;
|
||||
writePath: string;
|
||||
kind: "user" | "project" | "import";
|
||||
importKind?: string;
|
||||
shared: boolean;
|
||||
scope: "global" | "project";
|
||||
}
|
||||
|
||||
export interface ConfigDiscoveryPath {
|
||||
label: string;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface DiscoveredImportConfig {
|
||||
kind: ImportKind;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ConfigDiscoverySource extends ConfigDiscoveryPath {
|
||||
id: ConfigSourceSpec["id"];
|
||||
scope: ConfigSourceSpec["scope"];
|
||||
kind: "shared" | "pi";
|
||||
serverCount: number;
|
||||
}
|
||||
|
||||
export interface ImportConfigSummary extends DiscoveredImportConfig {
|
||||
serverCount: number;
|
||||
}
|
||||
|
||||
export interface RepoPromptDiscovery {
|
||||
configured: boolean;
|
||||
configuredPath?: string;
|
||||
executablePath?: string;
|
||||
targetPath?: string;
|
||||
serverName?: string;
|
||||
entry?: ServerEntry;
|
||||
}
|
||||
|
||||
export interface McpDiscoverySummary {
|
||||
sources: ConfigDiscoverySource[];
|
||||
imports: ImportConfigSummary[];
|
||||
hasAnyConfig: boolean;
|
||||
hasAnyDetectedPaths: boolean;
|
||||
hasSharedServers: boolean;
|
||||
hasPiOwnedServers: boolean;
|
||||
totalServerCount: number;
|
||||
fingerprint: string;
|
||||
repoPrompt: RepoPromptDiscovery;
|
||||
}
|
||||
|
||||
export interface ConfigWritePreview {
|
||||
path: string;
|
||||
existed: boolean;
|
||||
changed: boolean;
|
||||
beforeText: string;
|
||||
afterText: string;
|
||||
diffText: string;
|
||||
}
|
||||
|
||||
export function getPiGlobalConfigPath(overridePath?: string): string {
|
||||
return overridePath ? resolve(overridePath) : getAgentPath("mcp.json");
|
||||
}
|
||||
|
||||
export function getGenericGlobalConfigPath(): string {
|
||||
return GENERIC_GLOBAL_CONFIG_PATH;
|
||||
}
|
||||
|
||||
export function getProjectConfigPath(cwd = process.cwd()): string {
|
||||
return resolve(cwd, PROJECT_CONFIG_NAME);
|
||||
}
|
||||
|
||||
export function getProjectPiConfigPath(cwd = process.cwd()): string {
|
||||
return resolve(cwd, PROJECT_PI_CONFIG_NAME);
|
||||
}
|
||||
|
||||
export function getConfigDiscoveryPaths(overridePath?: string, cwd = process.cwd()): ConfigDiscoveryPath[] {
|
||||
return getConfigSources(overridePath, cwd).map((source) => ({
|
||||
label: source.label,
|
||||
path: source.readPath,
|
||||
exists: existsSync(source.readPath),
|
||||
}));
|
||||
}
|
||||
|
||||
export function findAvailableImportConfigs(cwd = process.cwd()): DiscoveredImportConfig[] {
|
||||
const discovered: DiscoveredImportConfig[] = [];
|
||||
|
||||
for (const importKind of Object.keys(IMPORT_PATHS) as ImportKind[]) {
|
||||
const importPath = resolveImportPath(importKind, cwd);
|
||||
if (importPath) {
|
||||
discovered.push({ kind: importKind, path: importPath });
|
||||
}
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
export function getMcpDiscoverySummary(overridePath?: string, cwd = process.cwd()): McpDiscoverySummary {
|
||||
const sources = getConfigSources(overridePath, cwd).map((source) => {
|
||||
const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
|
||||
return {
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
path: source.readPath,
|
||||
exists: existsSync(source.readPath),
|
||||
scope: source.scope,
|
||||
kind: source.shared ? "shared" : "pi",
|
||||
serverCount: loaded ? Object.keys(loaded.mcpServers).length : 0,
|
||||
} satisfies ConfigDiscoverySource;
|
||||
});
|
||||
|
||||
const imports = (Object.keys(IMPORT_PATHS) as ImportKind[])
|
||||
.map((kind) => {
|
||||
const path = resolveImportPath(kind, cwd);
|
||||
if (!path) return null;
|
||||
return {
|
||||
kind,
|
||||
path,
|
||||
serverCount: getImportServerCount(kind, path),
|
||||
} satisfies ImportConfigSummary;
|
||||
})
|
||||
.filter((value): value is ImportConfigSummary => value !== null);
|
||||
|
||||
const totalServerCount = sources.reduce((sum, source) => sum + source.serverCount, 0);
|
||||
const hasSharedServers = sources.some((source) => source.kind === "shared" && source.serverCount > 0);
|
||||
const hasPiOwnedServers = sources.some((source) => source.kind === "pi" && source.serverCount > 0);
|
||||
const hasAnyDetectedPaths = sources.some((source) => source.exists) || imports.length > 0;
|
||||
const hasAnyConfig = totalServerCount > 0 || imports.some((entry) => entry.serverCount > 0) || hasAnyDetectedPaths;
|
||||
|
||||
const summaryWithoutRepoPrompt = {
|
||||
sources,
|
||||
imports,
|
||||
hasAnyConfig,
|
||||
hasAnyDetectedPaths,
|
||||
hasSharedServers,
|
||||
hasPiOwnedServers,
|
||||
totalServerCount,
|
||||
};
|
||||
|
||||
const fingerprint = JSON.stringify({
|
||||
sources: sources.map((source) => [source.id, source.exists, source.serverCount]),
|
||||
imports: imports.map((entry) => [entry.kind, entry.path, entry.serverCount]),
|
||||
});
|
||||
|
||||
return {
|
||||
...summaryWithoutRepoPrompt,
|
||||
fingerprint,
|
||||
repoPrompt: detectRepoPrompt(summaryWithoutRepoPrompt, cwd),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadMcpConfig(overridePath?: string, cwd = process.cwd()): McpConfig {
|
||||
let config: McpConfig = { mcpServers: {} };
|
||||
|
||||
for (const source of getConfigSources(overridePath, cwd)) {
|
||||
const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
|
||||
if (!loaded) continue;
|
||||
config = mergeConfigs(config, expandImports(loaded, cwd));
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function getConfigSources(overridePath?: string, cwd = process.cwd()): ConfigSourceSpec[] {
|
||||
const userPath = getPiGlobalConfigPath(overridePath);
|
||||
const projectPath = getProjectConfigPath(cwd);
|
||||
const projectPiPath = getProjectPiConfigPath(cwd);
|
||||
const sources: ConfigSourceSpec[] = [];
|
||||
|
||||
if (GENERIC_GLOBAL_CONFIG_PATH !== userPath) {
|
||||
sources.push({
|
||||
id: "shared-global",
|
||||
label: "user-global standard MCP",
|
||||
readPath: GENERIC_GLOBAL_CONFIG_PATH,
|
||||
writePath: userPath,
|
||||
kind: "import",
|
||||
importKind: "global MCP config",
|
||||
shared: true,
|
||||
scope: "global",
|
||||
});
|
||||
}
|
||||
|
||||
sources.push({
|
||||
id: "pi-global",
|
||||
label: "Pi global override",
|
||||
readPath: userPath,
|
||||
writePath: userPath,
|
||||
kind: "user",
|
||||
shared: false,
|
||||
scope: "global",
|
||||
});
|
||||
|
||||
if (projectPath !== userPath) {
|
||||
sources.push({
|
||||
id: "shared-project",
|
||||
label: "project standard MCP",
|
||||
readPath: projectPath,
|
||||
writePath: projectPath,
|
||||
kind: "project",
|
||||
shared: true,
|
||||
scope: "project",
|
||||
});
|
||||
}
|
||||
|
||||
if (projectPiPath !== userPath && projectPiPath !== projectPath) {
|
||||
sources.push({
|
||||
id: "pi-project",
|
||||
label: "project Pi override",
|
||||
readPath: projectPiPath,
|
||||
writePath: projectPiPath,
|
||||
kind: "project",
|
||||
shared: false,
|
||||
scope: "project",
|
||||
});
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
function mergeConfigs(base: McpConfig, next: McpConfig): McpConfig {
|
||||
return {
|
||||
mcpServers: { ...base.mcpServers, ...next.mcpServers },
|
||||
imports: mergeImports(base.imports, next.imports),
|
||||
settings: next.settings ? { ...base.settings, ...next.settings } : base.settings,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeImports(left: ImportKind[] | undefined, right: ImportKind[] | undefined): ImportKind[] | undefined {
|
||||
const merged = [...(left ?? []), ...(right ?? [])];
|
||||
if (merged.length === 0) return undefined;
|
||||
return [...new Set(merged)];
|
||||
}
|
||||
|
||||
function expandImports(config: McpConfig, cwd = process.cwd()): McpConfig {
|
||||
if (!config.imports?.length) return config;
|
||||
|
||||
const importedServers: Record<string, ServerEntry> = {};
|
||||
for (const importKind of config.imports) {
|
||||
const importPath = resolveImportPath(importKind, cwd);
|
||||
if (!importPath) continue;
|
||||
|
||||
try {
|
||||
const imported = JSON.parse(readFileSync(importPath, "utf-8"));
|
||||
const servers = extractServers(imported, importKind);
|
||||
for (const [name, definition] of Object.entries(servers)) {
|
||||
if (!importedServers[name]) {
|
||||
importedServers[name] = definition;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to import MCP config from ${importKind}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
imports: config.imports,
|
||||
settings: config.settings,
|
||||
mcpServers: { ...importedServers, ...config.mcpServers },
|
||||
};
|
||||
}
|
||||
|
||||
function resolveImportPath(importKind: ImportKind, cwd = process.cwd()): string | null {
|
||||
const candidates = IMPORT_PATHS[importKind] ?? [];
|
||||
for (const candidate of candidates) {
|
||||
const fullPath = candidate.startsWith(".") ? resolve(cwd, candidate) : candidate;
|
||||
if (existsSync(fullPath)) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getImportServerCount(importKind: ImportKind, path: string): number {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
||||
return Object.keys(extractServers(raw, importKind)).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function readValidatedConfig(path: string, label: string): McpConfig | null {
|
||||
if (!existsSync(path)) return null;
|
||||
|
||||
try {
|
||||
return validateConfig(JSON.parse(readFileSync(path, "utf-8")));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load ${label}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function validateConfig(raw: unknown): McpConfig {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const servers = obj.mcpServers ?? obj["mcp-servers"] ?? {};
|
||||
|
||||
if (typeof servers !== "object" || servers === null || Array.isArray(servers)) {
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
|
||||
return {
|
||||
mcpServers: servers as Record<string, ServerEntry>,
|
||||
imports: Array.isArray(obj.imports) ? (obj.imports as ImportKind[]) : undefined,
|
||||
settings: obj.settings as McpSettings | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function extractServers(config: unknown, kind: ImportKind): Record<string, ServerEntry> {
|
||||
if (!config || typeof config !== "object") return {};
|
||||
|
||||
const obj = config as Record<string, unknown>;
|
||||
|
||||
let servers: unknown;
|
||||
switch (kind) {
|
||||
case "claude-desktop":
|
||||
case "claude-code":
|
||||
case "codex":
|
||||
servers = obj.mcpServers;
|
||||
break;
|
||||
case "cursor":
|
||||
case "windsurf":
|
||||
case "vscode":
|
||||
servers = obj.mcpServers ?? obj["mcp-servers"];
|
||||
break;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return servers as Record<string, ServerEntry>;
|
||||
}
|
||||
|
||||
function serializeRawConfig(raw: Record<string, unknown>): string {
|
||||
return `${JSON.stringify(raw, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function buildUnifiedDiff(beforeText: string, afterText: string): string {
|
||||
if (beforeText === afterText) return "(no changes)";
|
||||
|
||||
const before = beforeText.split("\n");
|
||||
const after = afterText.split("\n");
|
||||
const rows = before.length;
|
||||
const cols = after.length;
|
||||
const lcs = Array.from({ length: rows + 1 }, () => Array<number>(cols + 1).fill(0));
|
||||
|
||||
for (let i = rows - 1; i >= 0; i--) {
|
||||
for (let j = cols - 1; j >= 0; j--) {
|
||||
lcs[i][j] = before[i] === after[j]
|
||||
? lcs[i + 1][j + 1] + 1
|
||||
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = ["--- before", "+++ after"];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < rows || j < cols) {
|
||||
if (i < rows && j < cols && before[i] === after[j]) {
|
||||
lines.push(` ${before[i]}`);
|
||||
i++;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (j < cols && (i === rows || lcs[i][j + 1] >= lcs[i + 1][j])) {
|
||||
lines.push(`+ ${after[j]}`);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (i < rows) {
|
||||
lines.push(`- ${before[i]}`);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildConfigWritePreview(filePath: string, nextRaw: Record<string, unknown>): ConfigWritePreview {
|
||||
const existed = existsSync(filePath);
|
||||
const beforeRaw = readRawConfigObject(filePath);
|
||||
const beforeText = existed ? serializeRawConfig(beforeRaw) : "";
|
||||
const afterText = serializeRawConfig(nextRaw);
|
||||
return {
|
||||
path: filePath,
|
||||
existed,
|
||||
changed: beforeText !== afterText,
|
||||
beforeText,
|
||||
afterText,
|
||||
diffText: buildUnifiedDiff(beforeText, afterText),
|
||||
};
|
||||
}
|
||||
|
||||
function readRawConfigObject(filePath: string): Record<string, unknown> {
|
||||
if (!existsSync(filePath)) return {};
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeRawConfigObject(filePath: string, raw: Record<string, unknown>): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
||||
writeFileSync(tmpPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
|
||||
renameSync(tmpPath, filePath);
|
||||
}
|
||||
|
||||
function getServersObject(raw: Record<string, unknown>): Record<string, ServerEntry> {
|
||||
const existing = raw.mcpServers ?? raw["mcp-servers"] ?? {};
|
||||
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
||||
return {};
|
||||
}
|
||||
return existing as Record<string, ServerEntry>;
|
||||
}
|
||||
|
||||
function setServersObject(raw: Record<string, unknown>, servers: Record<string, ServerEntry>): void {
|
||||
delete raw["mcp-servers"];
|
||||
raw.mcpServers = servers;
|
||||
}
|
||||
|
||||
function isRepoPromptServer(name: string, entry: ServerEntry): boolean {
|
||||
const normalizedName = name.toLowerCase();
|
||||
if (normalizedName.includes("repoprompt") || normalizedName === "rp") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const command = entry.command?.toLowerCase() ?? "";
|
||||
if (command.includes("repoprompt") || command.includes("rp-mcp") || command.endsWith("repoprompt_cli")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (entry.args ?? []).some((arg) => typeof arg === "string" && arg.toLowerCase().includes("repoprompt"));
|
||||
}
|
||||
|
||||
function findProjectRoot(cwd = process.cwd()): string | null {
|
||||
let current = resolve(cwd);
|
||||
while (true) {
|
||||
if (
|
||||
existsSync(join(current, ".git"))
|
||||
|| existsSync(join(current, "package.json"))
|
||||
|| existsSync(join(current, PROJECT_CONFIG_NAME))
|
||||
|| existsSync(join(current, CONFIG_DIR_NAME))
|
||||
|| existsSync(join(current, ".pi"))
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRepoPromptEntry(executablePath: string): ServerEntry {
|
||||
return {
|
||||
command: executablePath,
|
||||
args: [],
|
||||
lifecycle: "lazy",
|
||||
};
|
||||
}
|
||||
|
||||
function detectRepoPrompt(summary: Omit<McpDiscoverySummary, "fingerprint" | "repoPrompt">, cwd = process.cwd()): RepoPromptDiscovery {
|
||||
for (const source of summary.sources) {
|
||||
if (source.kind !== "shared" || source.serverCount === 0) continue;
|
||||
const config = readValidatedConfig(source.path, `MCP config from ${source.path}`);
|
||||
if (!config) continue;
|
||||
for (const [name, entry] of Object.entries(config.mcpServers)) {
|
||||
if (isRepoPromptServer(name, entry)) {
|
||||
return { configured: true, configuredPath: source.path };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const executablePath = REPOPROMPT_BINARY_CANDIDATES.find((candidate) => existsSync(candidate));
|
||||
if (!executablePath) {
|
||||
return { configured: false };
|
||||
}
|
||||
|
||||
const projectRoot = findProjectRoot(cwd);
|
||||
const targetPath = projectRoot ? join(projectRoot, PROJECT_CONFIG_NAME) : GENERIC_GLOBAL_CONFIG_PATH;
|
||||
return {
|
||||
configured: false,
|
||||
executablePath,
|
||||
targetPath,
|
||||
serverName: "repoprompt",
|
||||
entry: buildRepoPromptEntry(executablePath),
|
||||
};
|
||||
}
|
||||
|
||||
export function previewCompatibilityImports(importKinds: ImportKind[], overridePath?: string): ConfigWritePreview {
|
||||
const targetPath = getPiGlobalConfigPath(overridePath);
|
||||
const raw = readRawConfigObject(targetPath);
|
||||
const currentImports = Array.isArray(raw.imports) ? raw.imports.filter((value): value is ImportKind => typeof value === "string") : [];
|
||||
const merged = [...new Set([...currentImports, ...importKinds])];
|
||||
const nextRaw = { ...raw, imports: merged };
|
||||
setServersObject(nextRaw, getServersObject(nextRaw));
|
||||
return buildConfigWritePreview(targetPath, nextRaw);
|
||||
}
|
||||
|
||||
export function ensureCompatibilityImports(importKinds: ImportKind[], overridePath?: string): { path: string; added: ImportKind[] } {
|
||||
const targetPath = getPiGlobalConfigPath(overridePath);
|
||||
const raw = readRawConfigObject(targetPath);
|
||||
const currentImports = Array.isArray(raw.imports) ? raw.imports.filter((value): value is ImportKind => typeof value === "string") : [];
|
||||
const merged = [...new Set([...currentImports, ...importKinds])];
|
||||
const added = merged.filter((kind) => !currentImports.includes(kind));
|
||||
if (added.length === 0) {
|
||||
return { path: targetPath, added: [] };
|
||||
}
|
||||
|
||||
raw.imports = merged;
|
||||
const servers = getServersObject(raw);
|
||||
setServersObject(raw, servers);
|
||||
writeRawConfigObject(targetPath, raw);
|
||||
return { path: targetPath, added };
|
||||
}
|
||||
|
||||
export function buildStarterProjectConfig(): McpConfig {
|
||||
return {
|
||||
mcpServers: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function previewStarterProjectConfig(cwd = process.cwd()): ConfigWritePreview {
|
||||
const targetPath = getProjectConfigPath(cwd);
|
||||
const nextRaw = { mcpServers: buildStarterProjectConfig().mcpServers };
|
||||
return buildConfigWritePreview(targetPath, nextRaw);
|
||||
}
|
||||
|
||||
export function writeStarterProjectConfig(cwd = process.cwd()): string {
|
||||
const targetPath = getProjectConfigPath(cwd);
|
||||
const raw = { mcpServers: buildStarterProjectConfig().mcpServers };
|
||||
writeRawConfigObject(targetPath, raw);
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function previewSharedServerEntry(filePath: string, serverName: string, entry: ServerEntry): ConfigWritePreview {
|
||||
const raw = readRawConfigObject(filePath);
|
||||
const nextRaw = { ...raw };
|
||||
const servers = getServersObject(nextRaw);
|
||||
servers[serverName] = entry;
|
||||
setServersObject(nextRaw, servers);
|
||||
return buildConfigWritePreview(filePath, nextRaw);
|
||||
}
|
||||
|
||||
export function writeSharedServerEntry(filePath: string, serverName: string, entry: ServerEntry): string {
|
||||
const raw = readRawConfigObject(filePath);
|
||||
const servers = getServersObject(raw);
|
||||
servers[serverName] = entry;
|
||||
setServersObject(raw, servers);
|
||||
writeRawConfigObject(filePath, raw);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function getServerProvenance(overridePath?: string, cwd = process.cwd()): Map<string, ServerProvenance> {
|
||||
const provenance = new Map<string, ServerProvenance>();
|
||||
const userPath = getPiGlobalConfigPath(overridePath);
|
||||
|
||||
for (const source of getConfigSources(overridePath, cwd)) {
|
||||
const loaded = readValidatedConfig(source.readPath, `MCP config from ${source.readPath}`);
|
||||
if (!loaded) continue;
|
||||
|
||||
if (loaded.imports?.length) {
|
||||
for (const importKind of loaded.imports) {
|
||||
const importPath = resolveImportPath(importKind, cwd);
|
||||
if (!importPath) continue;
|
||||
|
||||
try {
|
||||
const imported = JSON.parse(readFileSync(importPath, "utf-8"));
|
||||
const servers = extractServers(imported, importKind);
|
||||
for (const name of Object.keys(servers)) {
|
||||
if (!provenance.has(name)) {
|
||||
provenance.set(name, { path: userPath, kind: "import", importKind });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of Object.keys(loaded.mcpServers)) {
|
||||
provenance.set(name, {
|
||||
path: source.writePath,
|
||||
kind: source.kind,
|
||||
importKind: source.importKind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return provenance;
|
||||
}
|
||||
|
||||
export function writeDirectToolsConfig(
|
||||
changes: Map<string, true | string[] | false>,
|
||||
provenance: Map<string, ServerProvenance>,
|
||||
fullConfig: McpConfig,
|
||||
): void {
|
||||
const byPath = new Map<string, { name: string; value: true | string[] | false; prov: ServerProvenance }[]>();
|
||||
|
||||
for (const [serverName, value] of changes) {
|
||||
const prov = provenance.get(serverName);
|
||||
if (!prov) continue;
|
||||
|
||||
const targetPath = prov.path;
|
||||
|
||||
if (!byPath.has(targetPath)) byPath.set(targetPath, []);
|
||||
byPath.get(targetPath)!.push({ name: serverName, value, prov });
|
||||
}
|
||||
|
||||
for (const [filePath, entries] of byPath) {
|
||||
const raw = readRawConfigObject(filePath);
|
||||
const servers = getServersObject(raw);
|
||||
|
||||
for (const { name, value, prov } of entries) {
|
||||
if (prov.kind === "import") {
|
||||
const fullDef = fullConfig.mcpServers[name];
|
||||
if (fullDef) {
|
||||
servers[name] = { ...fullDef, directTools: value };
|
||||
}
|
||||
} else if (servers[name]) {
|
||||
servers[name] = { ...servers[name], directTools: value };
|
||||
}
|
||||
}
|
||||
|
||||
setServersObject(raw, servers);
|
||||
writeRawConfigObject(filePath, raw);
|
||||
}
|
||||
}
|
||||
64
agent/extensions/pi-mcp-adapter/consent-manager.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ConsentError } from "./errors.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
|
||||
export type ToolConsentMode = "never" | "once-per-server" | "always";
|
||||
|
||||
export class ConsentManager {
|
||||
private approvedServers = new Set<string>();
|
||||
private deniedServers = new Set<string>();
|
||||
private log = logger.child({ component: "ConsentManager" });
|
||||
|
||||
constructor(private mode: ToolConsentMode = "once-per-server") {
|
||||
this.log.debug("Initialized", { mode });
|
||||
}
|
||||
|
||||
requiresPrompt(serverName: string): boolean {
|
||||
if (this.mode === "never") return false;
|
||||
if (this.deniedServers.has(serverName)) return true;
|
||||
if (this.mode === "always") return true;
|
||||
return !this.approvedServers.has(serverName);
|
||||
}
|
||||
|
||||
shouldCacheConsent(): boolean {
|
||||
return this.mode !== "always";
|
||||
}
|
||||
|
||||
registerDecision(serverName: string, approved: boolean): void {
|
||||
this.deniedServers.delete(serverName);
|
||||
this.approvedServers.delete(serverName);
|
||||
|
||||
if (approved) {
|
||||
this.approvedServers.add(serverName);
|
||||
this.log.debug("Consent granted", { server: serverName });
|
||||
return;
|
||||
}
|
||||
|
||||
this.deniedServers.add(serverName);
|
||||
this.log.debug("Consent denied", { server: serverName });
|
||||
}
|
||||
|
||||
ensureApproved(serverName: string): void {
|
||||
if (this.mode === "never") return;
|
||||
if (this.deniedServers.has(serverName)) {
|
||||
throw new ConsentError(serverName, { denied: true });
|
||||
}
|
||||
if (!this.approvedServers.has(serverName)) {
|
||||
throw new ConsentError(serverName, { requiresApproval: true });
|
||||
}
|
||||
if (this.mode === "always") {
|
||||
this.approvedServers.delete(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
clear(serverName?: string): void {
|
||||
if (serverName) {
|
||||
this.approvedServers.delete(serverName);
|
||||
this.deniedServers.delete(serverName);
|
||||
this.log.debug("Cleared consent for server", { server: serverName });
|
||||
return;
|
||||
}
|
||||
this.approvedServers.clear();
|
||||
this.deniedServers.clear();
|
||||
this.log.debug("Cleared all consent records");
|
||||
}
|
||||
}
|
||||
441
agent/extensions/pi-mcp-adapter/direct-tools.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import type { DirectToolSpec, McpConfig, McpContent } from "./types.ts";
|
||||
import type { MetadataCache } from "./metadata-cache.ts";
|
||||
import { lazyConnect, getFailureAgeSeconds } from "./init.ts";
|
||||
import { isServerCacheValid } from "./metadata-cache.ts";
|
||||
import { formatSchema } from "./tool-metadata.ts";
|
||||
import { transformMcpContent } from "./tool-registrar.ts";
|
||||
import { maybeStartUiSession, type UiSessionRuntime } from "./ui-session.ts";
|
||||
import { formatToolName, isToolExcluded } from "./types.ts";
|
||||
import { resourceNameToToolName } from "./resource-tools.ts";
|
||||
import { authenticate, supportsOAuth } from "./mcp-auth-flow.ts";
|
||||
import { formatAuthRequiredMessage } from "./utils.ts";
|
||||
|
||||
const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
|
||||
|
||||
type DirectAutoAuthResult =
|
||||
| { status: "skipped" }
|
||||
| { status: "success" }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
function getDirectAuthRequiredMessage(
|
||||
state: McpExtensionState,
|
||||
serverName: string,
|
||||
defaultMessage = `MCP server "${serverName}" requires OAuth authentication. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`,
|
||||
): string {
|
||||
return formatAuthRequiredMessage(state.config, serverName, defaultMessage);
|
||||
}
|
||||
|
||||
function getDirectAuthFailedMessage(state: McpExtensionState, serverName: string, message: string): string {
|
||||
const customGuidance = state.config.settings?.authRequiredMessage;
|
||||
if (customGuidance) {
|
||||
return `OAuth authentication failed for "${serverName}": ${message}. ${getDirectAuthRequiredMessage(state, serverName)}`;
|
||||
}
|
||||
return `OAuth authentication failed for "${serverName}": ${message}. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`;
|
||||
}
|
||||
|
||||
async function attemptDirectAutoAuth(
|
||||
state: McpExtensionState,
|
||||
serverName: string,
|
||||
): Promise<DirectAutoAuthResult> {
|
||||
if (state.config.settings?.autoAuth !== true) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition || !supportsOAuth(definition) || !definition.url) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
const grantType = definition.oauth ? definition.oauth.grantType ?? "authorization_code" : "authorization_code";
|
||||
if (!state.ui && grantType !== "client_credentials") {
|
||||
return {
|
||||
status: "failed",
|
||||
message: getDirectAuthRequiredMessage(
|
||||
state,
|
||||
serverName,
|
||||
`MCP server "${serverName}" requires OAuth authentication. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await authenticate(serverName, definition.url, definition);
|
||||
return { status: "success" };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
status: "failed",
|
||||
message: getDirectAuthFailedMessage(state, serverName, message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDirectTools(
|
||||
config: McpConfig,
|
||||
cache: MetadataCache | null,
|
||||
prefix: "server" | "none" | "short",
|
||||
envOverride?: string[],
|
||||
): DirectToolSpec[] {
|
||||
const specs: DirectToolSpec[] = [];
|
||||
if (!cache) return specs;
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
const envServers = new Set<string>();
|
||||
const envTools = new Map<string, Set<string>>();
|
||||
if (envOverride) {
|
||||
for (let item of envOverride) {
|
||||
item = item.replace(/\/+$/, "");
|
||||
if (item.includes("/")) {
|
||||
const [server, tool] = item.split("/", 2);
|
||||
if (server && tool) {
|
||||
if (!envTools.has(server)) envTools.set(server, new Set());
|
||||
envTools.get(server)!.add(tool);
|
||||
} else if (server) {
|
||||
envServers.add(server);
|
||||
}
|
||||
} else if (item) {
|
||||
envServers.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const globalDirect = config.settings?.directTools;
|
||||
|
||||
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
||||
const serverCache = cache.servers[serverName];
|
||||
if (!serverCache || !isServerCacheValid(serverCache, definition)) continue;
|
||||
|
||||
let toolFilter: true | string[] | false = false;
|
||||
|
||||
if (envOverride) {
|
||||
if (envServers.has(serverName)) {
|
||||
toolFilter = true;
|
||||
} else if (envTools.has(serverName)) {
|
||||
toolFilter = [...envTools.get(serverName)!];
|
||||
}
|
||||
} else {
|
||||
if (definition.directTools !== undefined) {
|
||||
toolFilter = definition.directTools;
|
||||
} else if (globalDirect) {
|
||||
toolFilter = globalDirect;
|
||||
}
|
||||
}
|
||||
|
||||
if (!toolFilter) continue;
|
||||
|
||||
for (const tool of serverCache.tools ?? []) {
|
||||
if (toolFilter !== true && !toolFilter.includes(tool.name)) continue;
|
||||
if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) continue;
|
||||
const prefixedName = formatToolName(tool.name, serverName, prefix);
|
||||
if (BUILTIN_NAMES.has(prefixedName)) {
|
||||
console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`);
|
||||
continue;
|
||||
}
|
||||
if (seenNames.has(prefixedName)) {
|
||||
console.warn(`MCP: skipping duplicate direct tool "${prefixedName}" from "${serverName}"`);
|
||||
continue;
|
||||
}
|
||||
seenNames.add(prefixedName);
|
||||
specs.push({
|
||||
serverName,
|
||||
originalName: tool.name,
|
||||
prefixedName,
|
||||
description: tool.description ?? "",
|
||||
inputSchema: tool.inputSchema,
|
||||
uiResourceUri: tool.uiResourceUri,
|
||||
uiStreamMode: tool.uiStreamMode,
|
||||
});
|
||||
}
|
||||
|
||||
if (definition.exposeResources !== false) {
|
||||
for (const resource of serverCache.resources ?? []) {
|
||||
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
||||
if (toolFilter !== true && !toolFilter.includes(baseName)) continue;
|
||||
if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) continue;
|
||||
const prefixedName = formatToolName(baseName, serverName, prefix);
|
||||
if (BUILTIN_NAMES.has(prefixedName)) {
|
||||
console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`);
|
||||
continue;
|
||||
}
|
||||
if (seenNames.has(prefixedName)) {
|
||||
console.warn(`MCP: skipping duplicate direct resource tool "${prefixedName}" from "${serverName}"`);
|
||||
continue;
|
||||
}
|
||||
seenNames.add(prefixedName);
|
||||
specs.push({
|
||||
serverName,
|
||||
originalName: baseName,
|
||||
prefixedName,
|
||||
description: resource.description ?? `Read resource: ${resource.uri}`,
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return specs;
|
||||
}
|
||||
|
||||
export function getMissingConfiguredDirectToolServers(
|
||||
config: McpConfig,
|
||||
cache: MetadataCache | null,
|
||||
): string[] {
|
||||
const missing: string[] = [];
|
||||
const globalDirect = config.settings?.directTools;
|
||||
|
||||
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
||||
const hasDirectTools = definition.directTools !== undefined
|
||||
? !!definition.directTools
|
||||
: !!globalDirect;
|
||||
|
||||
if (!hasDirectTools) continue;
|
||||
|
||||
const serverCache = cache?.servers?.[serverName];
|
||||
if (!serverCache || !isServerCacheValid(serverCache, definition)) {
|
||||
missing.push(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function buildProxyDescription(
|
||||
config: McpConfig,
|
||||
cache: MetadataCache | null,
|
||||
directSpecs: DirectToolSpec[],
|
||||
): string {
|
||||
const prefix = config.settings?.toolPrefix ?? "server";
|
||||
let desc = `MCP gateway - connect to MCP servers and call their tools. Non-MCP Pi tools should be called directly, not through mcp.\n`;
|
||||
|
||||
const directByServer = new Map<string, number>();
|
||||
for (const spec of directSpecs) {
|
||||
directByServer.set(spec.serverName, (directByServer.get(spec.serverName) ?? 0) + 1);
|
||||
}
|
||||
if (directByServer.size > 0) {
|
||||
const parts = [...directByServer.entries()].map(
|
||||
([server, count]) => `${server} (${count})`,
|
||||
);
|
||||
desc += `\nDirect tools available (call as normal tools): ${parts.join(", ")}\n`;
|
||||
}
|
||||
|
||||
const serverSummaries: string[] = [];
|
||||
for (const serverName of Object.keys(config.mcpServers)) {
|
||||
const entry = cache?.servers?.[serverName];
|
||||
const definition = config.mcpServers[serverName];
|
||||
const toolCount = (entry?.tools ?? []).filter(
|
||||
(tool) => !isToolExcluded(tool.name, serverName, prefix, definition.excludeTools),
|
||||
).length;
|
||||
const resourceCount = definition?.exposeResources !== false
|
||||
? (entry?.resources ?? []).filter((resource) => {
|
||||
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
||||
return !isToolExcluded(baseName, serverName, prefix, definition.excludeTools);
|
||||
}).length
|
||||
: 0;
|
||||
const totalItems = toolCount + resourceCount;
|
||||
if (totalItems === 0) continue;
|
||||
const directCount = directByServer.get(serverName) ?? 0;
|
||||
const proxyCount = totalItems - directCount;
|
||||
if (proxyCount > 0) {
|
||||
serverSummaries.push(`${serverName} (${proxyCount} tools)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (serverSummaries.length > 0) {
|
||||
desc += `\nServers: ${serverSummaries.join(", ")}\n`;
|
||||
}
|
||||
|
||||
desc += `\nUsage:\n`;
|
||||
desc += ` mcp({ }) → Show server status\n`;
|
||||
desc += ` mcp({ server: "name" }) → List tools from server\n`;
|
||||
desc += ` mcp({ search: "query" }) → Search MCP tools by name/description\n`;
|
||||
desc += ` mcp({ describe: "tool_name" }) → Show tool details and parameters\n`;
|
||||
desc += ` mcp({ connect: "server-name" }) → Connect to a server and refresh metadata\n`;
|
||||
desc += ` mcp({ tool: "name", args: '{"key": "value"}' }) → Call a tool (args is JSON string)\n`;
|
||||
desc += ` mcp({ action: "ui-messages" }) → Retrieve accumulated messages from completed UI sessions\n`;
|
||||
desc += ` mcp({ action: "auth-start", server: "name" }) → Start manual OAuth and get a browser URL\n`;
|
||||
desc += ` mcp({ action: "auth-complete", server: "name", args: '{"redirectUrl":"..."}' }) → Complete manual OAuth\n`;
|
||||
desc += `\nMode: action > tool (call) > connect > describe > search > server (list) > nothing (status)`;
|
||||
|
||||
return desc;
|
||||
}
|
||||
|
||||
type DirectToolExecute = (
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined,
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<AgentToolResult<Record<string, unknown>>>;
|
||||
|
||||
export function createDirectToolExecutor(
|
||||
getState: () => McpExtensionState | null,
|
||||
getInitPromise: () => Promise<McpExtensionState> | null,
|
||||
spec: DirectToolSpec
|
||||
): DirectToolExecute {
|
||||
return async function execute(_toolCallId, params) {
|
||||
let state = getState();
|
||||
const initPromise = getInitPromise();
|
||||
|
||||
if (!state && initPromise) {
|
||||
try {
|
||||
state = await initPromise;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }],
|
||||
details: { error: "init_failed", message },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!state) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "MCP not initialized" }],
|
||||
details: { error: "not_initialized" },
|
||||
};
|
||||
}
|
||||
|
||||
let connected = await lazyConnect(state, spec.serverName);
|
||||
let autoAuthAttempted = false;
|
||||
|
||||
if (!connected && state.manager.getConnection(spec.serverName)?.status === "needs-auth") {
|
||||
autoAuthAttempted = true;
|
||||
const autoAuth = await attemptDirectAutoAuth(state, spec.serverName);
|
||||
if (autoAuth.status === "failed") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: autoAuth.message }],
|
||||
details: { error: "auth_required", server: spec.serverName, message: autoAuth.message },
|
||||
};
|
||||
}
|
||||
if (autoAuth.status === "success") {
|
||||
await state.manager.close(spec.serverName);
|
||||
state.failureTracker.delete(spec.serverName);
|
||||
connected = await lazyConnect(state, spec.serverName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
const authConnection = state.manager.getConnection(spec.serverName);
|
||||
if (authConnection?.status === "needs-auth") {
|
||||
const message = getDirectAuthRequiredMessage(state, spec.serverName);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { error: "auth_required", server: spec.serverName, message, autoAuthAttempted },
|
||||
};
|
||||
}
|
||||
const failedAgo = getFailureAgeSeconds(state, spec.serverName);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not available${failedAgo !== null ? ` (failed ${failedAgo}s ago)` : ""}` }],
|
||||
details: { error: "server_unavailable", server: spec.serverName },
|
||||
};
|
||||
}
|
||||
|
||||
const connection = state.manager.getConnection(spec.serverName);
|
||||
if (!connection || connection.status !== "connected") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not connected` }],
|
||||
details: { error: "not_connected", server: spec.serverName },
|
||||
};
|
||||
}
|
||||
|
||||
let uiSession: UiSessionRuntime | null = null;
|
||||
|
||||
try {
|
||||
state.manager.touch(spec.serverName);
|
||||
state.manager.incrementInFlight(spec.serverName);
|
||||
|
||||
if (spec.resourceUri) {
|
||||
const result = await connection.client.readResource({ uri: spec.resourceUri });
|
||||
const content = (result.contents ?? []).map(c => ({
|
||||
type: "text" as const,
|
||||
text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
|
||||
}));
|
||||
return {
|
||||
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }],
|
||||
details: { server: spec.serverName, resourceUri: spec.resourceUri },
|
||||
};
|
||||
}
|
||||
|
||||
const hasUi = !!spec.uiResourceUri;
|
||||
uiSession = hasUi
|
||||
? await maybeStartUiSession(state, {
|
||||
serverName: spec.serverName,
|
||||
toolName: spec.originalName,
|
||||
toolArgs: params ?? {},
|
||||
uiResourceUri: spec.uiResourceUri!,
|
||||
streamMode: spec.uiStreamMode,
|
||||
})
|
||||
: null;
|
||||
|
||||
const resultPromise = connection.client.callTool({
|
||||
name: spec.originalName,
|
||||
arguments: params ?? {},
|
||||
_meta: uiSession?.requestMeta,
|
||||
});
|
||||
|
||||
const result = await resultPromise;
|
||||
uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult);
|
||||
|
||||
const mcpContent = (result.content ?? []) as McpContent[];
|
||||
const content = transformMcpContent(mcpContent);
|
||||
|
||||
if (result.isError) {
|
||||
let errorText = content.filter(c => c.type === "text").map(c => (c as { text: string }).text).join("\n") || "Tool execution failed";
|
||||
if (spec.inputSchema) {
|
||||
errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error: ${errorText}` }],
|
||||
details: { error: "tool_error", server: spec.serverName },
|
||||
};
|
||||
}
|
||||
|
||||
const resultText = content.filter(c => c.type === "text").map(c => (c as { text: string }).text).join("\n") || "(empty result)";
|
||||
if (hasUi) {
|
||||
const uiMessage = uiSession?.reused
|
||||
? "Updated the open UI."
|
||||
: "📺 Interactive UI is now open in your browser. I'll respond to your prompts and intents as you interact with it.";
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `${resultText}\n\n${uiMessage}` }],
|
||||
details: { server: spec.serverName, tool: spec.originalName, uiOpen: true },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
|
||||
details: { server: spec.serverName, tool: spec.originalName },
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UrlElicitationRequiredError) {
|
||||
const action = await state.manager.handleUrlElicitationRequired(spec.serverName, error);
|
||||
const message = action === "accept"
|
||||
? "The original MCP tool did not run. Complete the opened browser interaction, then retry the tool."
|
||||
: `The URL interaction was ${action === "decline" ? "declined" : "cancelled"}.`;
|
||||
uiSession?.sendToolCancelled(message);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { error: "url_elicitation_required", server: spec.serverName, action },
|
||||
};
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
uiSession?.sendToolCancelled(message);
|
||||
let errorText = `Failed to call tool: ${message}`;
|
||||
if (spec.inputSchema) {
|
||||
errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: errorText }],
|
||||
details: { error: "call_failed", server: spec.serverName },
|
||||
};
|
||||
} finally {
|
||||
if (uiSession?.reused) {
|
||||
uiSession.close();
|
||||
}
|
||||
state.manager.decrementInFlight(spec.serverName);
|
||||
state.manager.touch(spec.serverName);
|
||||
}
|
||||
};
|
||||
}
|
||||
347
agent/extensions/pi-mcp-adapter/elicitation-handler.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import {
|
||||
ElicitRequestSchema,
|
||||
ErrorCode,
|
||||
McpError,
|
||||
type ElicitRequest,
|
||||
type ElicitRequestFormParams,
|
||||
type ElicitRequestURLParams,
|
||||
type ElicitResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
|
||||
import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types.js";
|
||||
import open from "open";
|
||||
|
||||
export type ElicitationValue = string | number | boolean | string[] | undefined;
|
||||
type FormProperty = ElicitRequestFormParams["requestedSchema"]["properties"][string];
|
||||
|
||||
export type ElicitationUIContext = ExtensionUIContext;
|
||||
|
||||
export interface ElicitationHandlerOptions {
|
||||
serverName: string;
|
||||
ui: ElicitationUIContext;
|
||||
allowUrl: boolean;
|
||||
onUrlAccepted?: (elicitationId: string) => void;
|
||||
}
|
||||
|
||||
export type ServerElicitationConfig = Omit<ElicitationHandlerOptions, "serverName" | "onUrlAccepted">;
|
||||
|
||||
export function registerElicitationHandler(client: Client, options: ElicitationHandlerOptions): void {
|
||||
client.setRequestHandler(ElicitRequestSchema, (request) =>
|
||||
handleElicitationRequest(options, request));
|
||||
}
|
||||
|
||||
export async function handleElicitationRequest(
|
||||
options: ElicitationHandlerOptions,
|
||||
request: ElicitRequest,
|
||||
): Promise<ElicitResult> {
|
||||
return request.params.mode === "url"
|
||||
? handleUrlElicitation(options, request.params)
|
||||
: handleFormElicitation(options, request.params);
|
||||
}
|
||||
|
||||
export async function handleFormElicitation(
|
||||
options: ElicitationHandlerOptions,
|
||||
params: ElicitRequestFormParams,
|
||||
): Promise<ElicitResult> {
|
||||
const decision = await options.ui.select(
|
||||
`MCP Input Request\nServer: ${options.serverName}\n\n${params.message}`,
|
||||
["Continue", "Decline"],
|
||||
);
|
||||
if (decision === undefined) return { action: "cancel" };
|
||||
if (decision === "Decline") return { action: "decline" };
|
||||
|
||||
const values: Record<string, ElicitationValue> = {};
|
||||
const properties = Object.entries(params.requestedSchema.properties);
|
||||
for (const [name, schema] of properties) {
|
||||
const value = await collectValidField(options.ui, params, name, schema);
|
||||
if (!("value" in value)) return { action: "cancel" };
|
||||
values[name] = value.value;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const content = coerceAndValidateFormValues(params, values);
|
||||
const action = await options.ui.select(
|
||||
formatReview(options.serverName, properties, content),
|
||||
properties.length > 0 ? ["Submit", "Edit", "Decline"] : ["Submit", "Decline"],
|
||||
);
|
||||
if (action === undefined) return { action: "cancel" };
|
||||
if (action === "Decline") return { action: "decline" };
|
||||
if (action === "Submit") return { action: "accept", content };
|
||||
|
||||
const labels = properties.map(([name, schema]) => `${schema.title ?? humanizeName(name)} (${name})`);
|
||||
const selected = await options.ui.select("Choose a field to edit", labels);
|
||||
if (selected === undefined) return { action: "cancel" };
|
||||
const property = properties[labels.indexOf(selected)];
|
||||
if (!property) continue;
|
||||
const [name, schema] = property;
|
||||
const value = await collectValidField(options.ui, params, name, schema, values[name]);
|
||||
if (!("value" in value)) return { action: "cancel" };
|
||||
values[name] = value.value;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectValidField(
|
||||
ui: ElicitationUIContext,
|
||||
params: ElicitRequestFormParams,
|
||||
name: string,
|
||||
schema: FormProperty,
|
||||
current?: ElicitationValue,
|
||||
): Promise<{ cancelled: true } | { cancelled: false; value: ElicitationValue }> {
|
||||
const required = params.requestedSchema.required?.includes(name) === true;
|
||||
while (true) {
|
||||
const result = await collectField(ui, params, name, schema, current);
|
||||
if (!("value" in result)) return result;
|
||||
try {
|
||||
coerceAndValidateFormValues({
|
||||
...params,
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: { [name]: schema },
|
||||
...(required ? { required: [name] } : {}),
|
||||
},
|
||||
}, { [name]: result.value });
|
||||
return result;
|
||||
} catch (error) {
|
||||
ui.notify(error instanceof Error ? error.message : String(error), "error");
|
||||
current = result.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectField(
|
||||
ui: ElicitationUIContext,
|
||||
params: ElicitRequestFormParams,
|
||||
name: string,
|
||||
schema: FormProperty,
|
||||
current?: ElicitationValue,
|
||||
): Promise<{ cancelled: true } | { cancelled: false; value: ElicitationValue }> {
|
||||
const required = params.requestedSchema.required?.includes(name) === true;
|
||||
const title = [schema.title ?? humanizeName(name), required ? "(required)" : "", schema.description]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
if (schema.type === "string" && ("enum" in schema || "oneOf" in schema)) {
|
||||
const choices = "oneOf" in schema
|
||||
? schema.oneOf.map(option => ({ value: option.const, display: formatChoice(option.const, option.title) }))
|
||||
: schema.enum.map((value, index) => ({
|
||||
value,
|
||||
display: formatChoice(value, "enumNames" in schema ? schema.enumNames?.[index] : undefined),
|
||||
}));
|
||||
const displays = uniqueLabels(choices.map(choice => choice.display));
|
||||
const actions = [...displays];
|
||||
const useDefault = schema.default === undefined ? undefined : uniqueAction("Use default", actions);
|
||||
if (useDefault) actions.push(useDefault);
|
||||
const omit = required ? undefined : uniqueAction("Omit", actions);
|
||||
if (omit) actions.push(omit);
|
||||
const action = await ui.select(title, actions);
|
||||
if (action === undefined) return { cancelled: true };
|
||||
if (action === useDefault) return { cancelled: false, value: schema.default };
|
||||
if (action === omit) return { cancelled: false, value: undefined };
|
||||
return { cancelled: false, value: choices[displays.indexOf(action)]?.value };
|
||||
}
|
||||
|
||||
if (schema.type === "boolean") {
|
||||
const actions = ["Yes", "No"];
|
||||
if (schema.default !== undefined) actions.push("Use default");
|
||||
if (!required) actions.push("Omit");
|
||||
const action = await ui.select(title, actions);
|
||||
if (action === undefined) return { cancelled: true };
|
||||
if (action === "Use default") return { cancelled: false, value: schema.default };
|
||||
if (action === "Omit") return { cancelled: false, value: undefined };
|
||||
return { cancelled: false, value: action === "Yes" };
|
||||
}
|
||||
|
||||
if (schema.type === "array") {
|
||||
const actions = ["Choose values"];
|
||||
if (schema.default !== undefined) actions.push("Use default");
|
||||
if (!required) actions.push("Omit");
|
||||
const action = await ui.select(title, actions);
|
||||
if (action === undefined) return { cancelled: true };
|
||||
if (action === "Use default") return { cancelled: false, value: schema.default };
|
||||
if (action === "Omit") return { cancelled: false, value: undefined };
|
||||
|
||||
const choices = extractMultiSelectOptions(schema);
|
||||
const selected = new Set(Array.isArray(current) ? current : []);
|
||||
while (true) {
|
||||
const displays = uniqueLabels(choices.map(choice => selected.has(choice.value) ? `✓ ${choice.display}` : choice.display));
|
||||
const done = uniqueAction("Done", displays);
|
||||
const picked = await ui.select(title, [...displays, done]);
|
||||
if (picked === undefined) return { cancelled: true };
|
||||
if (picked === done) return { cancelled: false, value: [...selected] };
|
||||
const choice = choices[displays.indexOf(picked)];
|
||||
if (!choice) continue;
|
||||
if (selected.has(choice.value)) selected.delete(choice.value);
|
||||
else selected.add(choice.value);
|
||||
}
|
||||
}
|
||||
|
||||
const actions = ["Enter value"];
|
||||
if (schema.default !== undefined) actions.push("Use default");
|
||||
if (!required) actions.push("Omit");
|
||||
const action = await ui.select(title, actions);
|
||||
if (action === undefined) return { cancelled: true };
|
||||
if (action === "Use default") return { cancelled: false, value: schema.default };
|
||||
if (action === "Omit") return { cancelled: false, value: undefined };
|
||||
const entered = await ui.input(title, current === undefined ? undefined : String(current));
|
||||
return entered === undefined ? { cancelled: true } : { cancelled: false, value: entered };
|
||||
}
|
||||
|
||||
export function coerceAndValidateFormValues(
|
||||
params: ElicitRequestFormParams,
|
||||
values: Record<string, ElicitationValue>,
|
||||
): Record<string, string | number | boolean | string[]> {
|
||||
const output: Record<string, string | number | boolean | string[]> = {};
|
||||
const required = new Set(params.requestedSchema.required ?? []);
|
||||
for (const [name, schema] of Object.entries(params.requestedSchema.properties)) {
|
||||
const value = values[name];
|
||||
if (value === undefined) {
|
||||
if (required.has(name)) throw new Error(`Missing required elicitation field: ${name}`);
|
||||
continue;
|
||||
}
|
||||
if (schema.type === "string") {
|
||||
const stringValue = String(value);
|
||||
const limits = schema as typeof schema & { minLength?: number; maxLength?: number };
|
||||
if (limits.minLength !== undefined && stringValue.length < limits.minLength) {
|
||||
throw new Error(`Elicitation field ${name} is shorter than minimum length ${limits.minLength}`);
|
||||
}
|
||||
if (limits.maxLength !== undefined && stringValue.length > limits.maxLength) {
|
||||
throw new Error(`Elicitation field ${name} is longer than maximum length ${limits.maxLength}`);
|
||||
}
|
||||
if ("enum" in schema && !schema.enum.includes(stringValue)) {
|
||||
throw new Error(`Elicitation field ${name} is not an allowed value`);
|
||||
}
|
||||
if ("oneOf" in schema && !schema.oneOf.some(option => option.const === stringValue)) {
|
||||
throw new Error(`Elicitation field ${name} is not an allowed value`);
|
||||
}
|
||||
output[name] = stringValue;
|
||||
continue;
|
||||
}
|
||||
if (schema.type === "number" || schema.type === "integer") {
|
||||
if (typeof value === "string" && value.trim() === "") {
|
||||
throw new Error(`Elicitation field ${name} must be a number`);
|
||||
}
|
||||
const numberValue = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(numberValue)) throw new Error(`Elicitation field ${name} must be a number`);
|
||||
if (schema.type === "integer" && !Number.isInteger(numberValue)) {
|
||||
throw new Error(`Elicitation field ${name} must be an integer`);
|
||||
}
|
||||
if (schema.minimum !== undefined && numberValue < schema.minimum) {
|
||||
throw new Error(`Elicitation field ${name} is below minimum ${schema.minimum}`);
|
||||
}
|
||||
if (schema.maximum !== undefined && numberValue > schema.maximum) {
|
||||
throw new Error(`Elicitation field ${name} is above maximum ${schema.maximum}`);
|
||||
}
|
||||
output[name] = numberValue;
|
||||
continue;
|
||||
}
|
||||
if (schema.type === "boolean") {
|
||||
output[name] = typeof value === "boolean" ? value : value === "true";
|
||||
continue;
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
if (!Array.isArray(value)) throw new Error(`Elicitation field ${name} must be a list`);
|
||||
const allowed = new Set(extractMultiSelectOptions(schema).map(option => option.value));
|
||||
const arrayValue = value.map(String);
|
||||
if (schema.minItems !== undefined && arrayValue.length < schema.minItems) {
|
||||
throw new Error(`Elicitation field ${name} has fewer than ${schema.minItems} selections`);
|
||||
}
|
||||
if (schema.maxItems !== undefined && arrayValue.length > schema.maxItems) {
|
||||
throw new Error(`Elicitation field ${name} has more than ${schema.maxItems} selections`);
|
||||
}
|
||||
if (arrayValue.some(item => !allowed.has(item))) {
|
||||
throw new Error(`Elicitation field ${name} contains an invalid selection`);
|
||||
}
|
||||
output[name] = arrayValue;
|
||||
}
|
||||
}
|
||||
const validation = new AjvJsonSchemaValidator()
|
||||
.getValidator(params.requestedSchema as JsonSchemaType)(output);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid elicitation response: ${validation.errorMessage}`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function formatChoice(value: string, title?: string): string {
|
||||
return title && title !== value ? `${title} (${value})` : value;
|
||||
}
|
||||
|
||||
function uniqueLabels(labels: string[]): string[] {
|
||||
const used = new Set<string>();
|
||||
return labels.map(label => {
|
||||
let unique = label;
|
||||
while (used.has(unique)) unique += "…";
|
||||
used.add(unique);
|
||||
return unique;
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueAction(label: string, choices: string[]): string {
|
||||
let unique = label;
|
||||
while (choices.includes(unique)) unique += "…";
|
||||
return unique;
|
||||
}
|
||||
|
||||
function extractMultiSelectOptions(schema: Extract<FormProperty, { type: "array" }>): Array<{ value: string; display: string }> {
|
||||
const items = schema.items as { enum?: string[]; anyOf?: Array<{ const: string; title: string }> };
|
||||
return items.anyOf
|
||||
? items.anyOf.map(option => ({ value: option.const, display: formatChoice(option.const, option.title) }))
|
||||
: (items.enum ?? []).map(value => ({ value, display: value }));
|
||||
}
|
||||
|
||||
function formatReview(
|
||||
serverName: string,
|
||||
properties: Array<[string, FormProperty]>,
|
||||
content: Record<string, string | number | boolean | string[]>,
|
||||
): string {
|
||||
const rows = properties.map(([name, schema]) =>
|
||||
`${schema.title ?? humanizeName(name)}: ${content[name] === undefined ? "(omitted)" : String(content[name])}`);
|
||||
return [`Review input for ${serverName}`, "", ...rows].join("\n");
|
||||
}
|
||||
|
||||
export async function handleUrlElicitation(
|
||||
options: ElicitationHandlerOptions,
|
||||
params: ElicitRequestURLParams,
|
||||
): Promise<ElicitResult> {
|
||||
if (!options.allowUrl) throw new McpError(ErrorCode.InvalidParams, "URL elicitation is not supported");
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(params.url);
|
||||
} catch {
|
||||
throw new McpError(ErrorCode.InvalidParams, "URL elicitation supplied an invalid URL");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new McpError(ErrorCode.InvalidParams, "URL elicitation only supports HTTP and HTTPS URLs");
|
||||
}
|
||||
|
||||
const decision = await options.ui.select([
|
||||
"MCP Browser Request",
|
||||
`Server: ${options.serverName}`,
|
||||
"",
|
||||
params.message,
|
||||
"",
|
||||
`Host: ${parsed.host}`,
|
||||
`Full URL: ${params.url}`,
|
||||
"",
|
||||
"Open this URL in your browser?",
|
||||
].join("\n"), ["Open", "Decline"]);
|
||||
if (decision === undefined) return { action: "cancel" };
|
||||
if (decision === "Decline") return { action: "decline" };
|
||||
|
||||
try {
|
||||
await open(params.url);
|
||||
} catch (error) {
|
||||
options.ui.notify(`Could not open MCP elicitation URL: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
return { action: "cancel" };
|
||||
}
|
||||
options.onUrlAccepted?.(params.elicitationId);
|
||||
options.ui.notify("Opened browser for MCP elicitation.", "info");
|
||||
return { action: "accept" };
|
||||
}
|
||||
|
||||
function humanizeName(name: string): string {
|
||||
return name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, char => char.toUpperCase());
|
||||
}
|
||||
219
agent/extensions/pi-mcp-adapter/errors.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Custom error types for MCP UI operations.
|
||||
* Provides structured errors with context and recovery hints.
|
||||
*/
|
||||
|
||||
export interface McpUiErrorContext {
|
||||
server?: string;
|
||||
tool?: string;
|
||||
uri?: string;
|
||||
session?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base error class for MCP UI errors.
|
||||
*/
|
||||
export class McpUiError extends Error {
|
||||
readonly code: string;
|
||||
readonly context: McpUiErrorContext;
|
||||
readonly recoveryHint?: string;
|
||||
readonly cause?: Error;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options: {
|
||||
code: string;
|
||||
context?: McpUiErrorContext;
|
||||
recoveryHint?: string;
|
||||
cause?: Error;
|
||||
}
|
||||
) {
|
||||
super(message);
|
||||
this.name = "McpUiError";
|
||||
this.code = options.code;
|
||||
this.context = options.context ?? {};
|
||||
this.recoveryHint = options.recoveryHint;
|
||||
this.cause = options.cause;
|
||||
|
||||
// Maintain proper stack trace
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
name: this.name,
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
context: this.context,
|
||||
recoveryHint: this.recoveryHint,
|
||||
stack: this.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error fetching a UI resource from the MCP server.
|
||||
*/
|
||||
export class ResourceFetchError extends McpUiError {
|
||||
constructor(
|
||||
uri: string,
|
||||
reason: string,
|
||||
options?: { server?: string; cause?: Error }
|
||||
) {
|
||||
super(`Failed to fetch UI resource "${uri}": ${reason}`, {
|
||||
code: "RESOURCE_FETCH_ERROR",
|
||||
context: { uri, server: options?.server },
|
||||
recoveryHint: "Check that the MCP server is connected and the resource URI is valid.",
|
||||
cause: options?.cause,
|
||||
});
|
||||
this.name = "ResourceFetchError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error parsing or validating UI resource content.
|
||||
*/
|
||||
export class ResourceParseError extends McpUiError {
|
||||
constructor(
|
||||
uri: string,
|
||||
reason: string,
|
||||
options?: { server?: string; mimeType?: string }
|
||||
) {
|
||||
super(`Invalid UI resource "${uri}": ${reason}`, {
|
||||
code: "RESOURCE_PARSE_ERROR",
|
||||
context: { uri, server: options?.server, mimeType: options?.mimeType },
|
||||
recoveryHint: "Ensure the resource returns valid HTML with the correct MIME type.",
|
||||
});
|
||||
this.name = "ResourceParseError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error connecting to the AppBridge.
|
||||
*/
|
||||
export class BridgeConnectionError extends McpUiError {
|
||||
constructor(reason: string, options?: { session?: string; cause?: Error }) {
|
||||
super(`AppBridge connection failed: ${reason}`, {
|
||||
code: "BRIDGE_CONNECTION_ERROR",
|
||||
context: { session: options?.session },
|
||||
recoveryHint: "Check browser console for detailed errors. The iframe may have failed to load.",
|
||||
cause: options?.cause,
|
||||
});
|
||||
this.name = "BridgeConnectionError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error related to user consent for tool calls.
|
||||
*/
|
||||
export class ConsentError extends McpUiError {
|
||||
readonly denied: boolean;
|
||||
|
||||
constructor(
|
||||
server: string,
|
||||
options: { denied?: boolean; requiresApproval?: boolean }
|
||||
) {
|
||||
const message = options.denied
|
||||
? `Tool calls for "${server}" were denied for this session`
|
||||
: `Tool call approval required for "${server}"`;
|
||||
|
||||
super(message, {
|
||||
code: options.denied ? "CONSENT_DENIED" : "CONSENT_REQUIRED",
|
||||
context: { server },
|
||||
recoveryHint: options.denied
|
||||
? "The user denied tool access. Start a new session to try again."
|
||||
: "Prompt the user for consent before calling tools.",
|
||||
});
|
||||
this.name = "ConsentError";
|
||||
this.denied = options.denied ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error with UI server session management.
|
||||
*/
|
||||
export class SessionError extends McpUiError {
|
||||
constructor(
|
||||
reason: string,
|
||||
options?: { session?: string; cause?: Error }
|
||||
) {
|
||||
super(`Session error: ${reason}`, {
|
||||
code: "SESSION_ERROR",
|
||||
context: { session: options?.session },
|
||||
recoveryHint: "The session may have expired or been closed. Try opening the UI again.",
|
||||
cause: options?.cause,
|
||||
});
|
||||
this.name = "SessionError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error starting or operating the UI server.
|
||||
*/
|
||||
export class ServerError extends McpUiError {
|
||||
constructor(
|
||||
reason: string,
|
||||
options?: { port?: number; cause?: Error }
|
||||
) {
|
||||
super(`UI server error: ${reason}`, {
|
||||
code: "SERVER_ERROR",
|
||||
context: { port: options?.port },
|
||||
recoveryHint: "Check if the port is available. Another process may be using it.",
|
||||
cause: options?.cause,
|
||||
});
|
||||
this.name = "ServerError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error communicating with the MCP server.
|
||||
*/
|
||||
export class McpServerError extends McpUiError {
|
||||
constructor(
|
||||
server: string,
|
||||
reason: string,
|
||||
options?: { tool?: string; cause?: Error }
|
||||
) {
|
||||
super(`MCP server "${server}" error: ${reason}`, {
|
||||
code: "MCP_SERVER_ERROR",
|
||||
context: { server, tool: options?.tool },
|
||||
recoveryHint: "Check that the MCP server is running and responsive.",
|
||||
cause: options?.cause,
|
||||
});
|
||||
this.name = "McpServerError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an unknown error into an McpUiError.
|
||||
*/
|
||||
export function wrapError(error: unknown, context?: McpUiErrorContext): McpUiError {
|
||||
if (error instanceof McpUiError) {
|
||||
// Merge contexts
|
||||
return new McpUiError(error.message, {
|
||||
code: error.code,
|
||||
context: { ...error.context, ...context },
|
||||
recoveryHint: error.recoveryHint,
|
||||
cause: error.cause,
|
||||
});
|
||||
}
|
||||
|
||||
const cause = error instanceof Error ? error : undefined;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
return new McpUiError(message, {
|
||||
code: "UNKNOWN_ERROR",
|
||||
context,
|
||||
cause,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a specific MCP UI error type.
|
||||
*/
|
||||
export function isErrorCode(error: unknown, code: string): boolean {
|
||||
return error instanceof McpUiError && error.code === code;
|
||||
}
|
||||
80
agent/extensions/pi-mcp-adapter/glimpse-ui.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { join, dirname } from "node:path";
|
||||
import { platform } from "node:os";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
let glimpseAvailable: boolean | null = null;
|
||||
let resolvedBinaryPath: string | null = null;
|
||||
|
||||
export function isGlimpseAvailable(): boolean {
|
||||
if (glimpseAvailable !== null) return glimpseAvailable;
|
||||
|
||||
if (platform() !== "darwin") {
|
||||
glimpseAvailable = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
resolvedBinaryPath = getGlimpseBinaryPath();
|
||||
glimpseAvailable = resolvedBinaryPath !== null;
|
||||
return glimpseAvailable;
|
||||
}
|
||||
|
||||
function getGlimpseBinaryPath(): string | null {
|
||||
if (process.env.GLIMPSE_BINARY && existsSync(process.env.GLIMPSE_BINARY)) {
|
||||
return process.env.GLIMPSE_BINARY;
|
||||
}
|
||||
|
||||
// Local node_modules
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const glimpseuiPath = require.resolve("glimpseui");
|
||||
const binaryPath = join(dirname(glimpseuiPath), "glimpse");
|
||||
if (existsSync(binaryPath)) return binaryPath;
|
||||
} catch {}
|
||||
|
||||
// Global npm install
|
||||
try {
|
||||
const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf-8" }).trim();
|
||||
const binaryPath = join(globalRoot, "glimpseui", "src", "glimpse");
|
||||
if (existsSync(binaryPath)) return binaryPath;
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function openGlimpseWindow(
|
||||
html: string,
|
||||
options: {
|
||||
title: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
onClosed: () => void;
|
||||
},
|
||||
) {
|
||||
const modulePath = resolvedBinaryPath
|
||||
? join(dirname(resolvedBinaryPath), "glimpse.mjs")
|
||||
: "glimpseui";
|
||||
const glimpse = await import(modulePath);
|
||||
|
||||
let active = true;
|
||||
const win = glimpse.open(html, {
|
||||
width: options.width ?? 900,
|
||||
height: options.height ?? 700,
|
||||
title: options.title,
|
||||
});
|
||||
|
||||
win.on("closed", () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
options.onClosed();
|
||||
});
|
||||
|
||||
return {
|
||||
close: () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
win.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
427
agent/extensions/pi-mcp-adapter/host-html-template.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import type { UiHostContext, UiResourceContent, UiResourceCsp } from "./types.ts";
|
||||
|
||||
// Use locally bundled AppBridge to avoid CDN Zod bundling issues
|
||||
const DEFAULT_APP_BRIDGE_MODULE_URL = "/app-bridge.bundle.js";
|
||||
|
||||
export interface HostHtmlTemplateInput {
|
||||
sessionToken: string;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
resource: UiResourceContent;
|
||||
allowAttribute: string;
|
||||
requireToolConsent: boolean;
|
||||
cacheToolConsent: boolean;
|
||||
hostContext?: UiHostContext;
|
||||
appBridgeModuleUrl?: string;
|
||||
}
|
||||
|
||||
export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
|
||||
const cspContent = buildCspMetaContent(input.resource.meta.csp);
|
||||
const resourceHtml = applyCspMeta(input.resource.html, cspContent);
|
||||
const hostContext = input.hostContext ?? {};
|
||||
|
||||
const sessionToken = safeInlineJSON(input.sessionToken);
|
||||
const toolArgs = safeInlineJSON(input.toolArgs);
|
||||
const uiHtml = safeInlineJSON(resourceHtml);
|
||||
const serverName = safeInlineJSON(input.serverName);
|
||||
const toolName = safeInlineJSON(input.toolName);
|
||||
const hostContextJson = safeInlineJSON(hostContext);
|
||||
const allowAttribute = safeInlineJSON(input.allowAttribute);
|
||||
const requireToolConsent = safeInlineJSON(input.requireToolConsent);
|
||||
const cacheToolConsent = safeInlineJSON(input.cacheToolConsent);
|
||||
const moduleUrl = safeInlineJSON(input.appBridgeModuleUrl ?? DEFAULT_APP_BRIDGE_MODULE_URL);
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MCP UI - ${escapeHtml(input.serverName)} / ${escapeHtml(input.toolName)}</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #0f1115;
|
||||
--surface: #181c22;
|
||||
--text: #ecf0f5;
|
||||
--muted: #a9b2bf;
|
||||
--accent: #43c0ff;
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
--good: #34d399;
|
||||
--warn: #fbbf24;
|
||||
--bad: #f87171;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f6f7fb;
|
||||
--surface: #ffffff;
|
||||
--text: #1d2939;
|
||||
--muted: #667085;
|
||||
--accent: #0ea5e9;
|
||||
--border: rgba(15, 23, 42, 0.14);
|
||||
--good: #059669;
|
||||
--warn: #b45309;
|
||||
--bad: #b91c1c;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; height: 100%; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); }
|
||||
body { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.title { display: flex; gap: 8px; align-items: baseline; min-width: 0; }
|
||||
.server { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; white-space: nowrap; }
|
||||
.tool { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.badge { border: 1px solid var(--border); border-radius: 999px; padding: 2px 8px; font-size: 11px; color: var(--muted); white-space: nowrap; }
|
||||
.controls { display: flex; gap: 8px; align-items: center; }
|
||||
.status { font-size: 12px; color: var(--muted); white-space: nowrap; }
|
||||
button { border: 1px solid var(--border); background: transparent; color: var(--text); border-radius: 8px; padding: 6px 10px; cursor: pointer; font-size: 12px; }
|
||||
button.primary { border-color: color-mix(in srgb, var(--good) 40%, var(--border) 60%); color: var(--good); }
|
||||
button.danger { border-color: color-mix(in srgb, var(--bad) 40%, var(--border) 60%); color: var(--bad); }
|
||||
button:hover { background: color-mix(in srgb, var(--surface) 75%, var(--accent) 25%); }
|
||||
main { flex: 1; min-height: 0; padding: 10px; display: flex; }
|
||||
iframe { width: 100%; height: 100%; border: 1px solid var(--border); border-radius: 10px; background: white; }
|
||||
.overlay { position: fixed; inset: 0; background: color-mix(in srgb, var(--bg) 90%, black 10%); display: none; align-items: center; justify-content: center; z-index: 2; }
|
||||
.overlay.visible { display: flex; }
|
||||
.panel { width: min(680px, calc(100vw - 40px)); background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 18px; }
|
||||
.panel h2 { margin: 0 0 8px; font-size: 16px; }
|
||||
.panel p { margin: 0; color: var(--muted); line-height: 1.4; font-size: 14px; white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="title">
|
||||
<span class="server">MCP · <span id="server-name"></span></span>
|
||||
<span class="tool" id="tool-name"></span>
|
||||
<span class="badge">Sandboxed</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<span class="status" id="status">Loading UI...</span>
|
||||
<button class="primary" id="done-btn" title="Cmd/Ctrl+Enter">Done</button>
|
||||
<button class="danger" id="cancel-btn" title="Escape">Cancel</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<iframe id="mcp-app" referrerpolicy="no-referrer"></iframe>
|
||||
</main>
|
||||
<div class="overlay" id="error-overlay">
|
||||
<div class="panel">
|
||||
<h2>UI Error</h2>
|
||||
<p id="error-message"></p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module">
|
||||
import { AppBridge, PostMessageTransport } from ${moduleUrl};
|
||||
|
||||
const SESSION_TOKEN = ${sessionToken};
|
||||
const SERVER_NAME = ${serverName};
|
||||
const TOOL_NAME = ${toolName};
|
||||
const TOOL_ARGS = ${toolArgs};
|
||||
const HOST_CONTEXT = ${hostContextJson};
|
||||
const ALLOW_ATTRIBUTE = ${allowAttribute};
|
||||
const REQUIRE_TOOL_CONSENT = ${requireToolConsent};
|
||||
const CACHE_TOOL_CONSENT = ${cacheToolConsent};
|
||||
const STREAM_CONTEXT_KEY = "pi-mcp-adapter/stream";
|
||||
const STREAM_PATCH_METHOD = "notifications/pi-mcp-adapter/ui-result-patch";
|
||||
|
||||
const iframe = document.getElementById("mcp-app");
|
||||
const statusNode = document.getElementById("status");
|
||||
const doneBtn = document.getElementById("done-btn");
|
||||
const cancelBtn = document.getElementById("cancel-btn");
|
||||
const errorOverlay = document.getElementById("error-overlay");
|
||||
const errorMessage = document.getElementById("error-message");
|
||||
|
||||
document.getElementById("server-name").textContent = SERVER_NAME;
|
||||
document.getElementById("tool-name").textContent = TOOL_NAME;
|
||||
|
||||
const setStatus = (text, isError = false) => {
|
||||
statusNode.textContent = text;
|
||||
statusNode.style.color = isError ? "var(--bad)" : "var(--muted)";
|
||||
};
|
||||
|
||||
const showError = (message) => {
|
||||
errorMessage.textContent = message;
|
||||
errorOverlay.classList.add("visible");
|
||||
setStatus("Error", true);
|
||||
};
|
||||
|
||||
const post = async (endpoint, params) => {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: SESSION_TOKEN, params }),
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({ ok: false, error: "Invalid JSON response" }));
|
||||
if (!response.ok || !body.ok) {
|
||||
const message = body.error || ("HTTP " + response.status);
|
||||
throw new Error(message);
|
||||
}
|
||||
return body.result ?? {};
|
||||
};
|
||||
|
||||
let consentGranted = !REQUIRE_TOOL_CONSENT;
|
||||
const initialStreamContext = HOST_CONTEXT?.[STREAM_CONTEXT_KEY];
|
||||
const streamMode = initialStreamContext?.mode === "stream-first" ? "stream-first" : "eager";
|
||||
|
||||
const bridge = new AppBridge(
|
||||
null,
|
||||
{ name: "pi", version: "1.0.0" },
|
||||
{ serverTools: {}, openLinks: {}, logging: {}, updateModelContext: {}, message: {} },
|
||||
{ hostContext: HOST_CONTEXT }
|
||||
);
|
||||
|
||||
bridge.oncalltool = async (params) => {
|
||||
if (!consentGranted) {
|
||||
const accepted = window.confirm("Allow this UI to call server tools for this session?");
|
||||
if (!accepted) {
|
||||
await post("/proxy/ui/consent", { approved: false }).catch(() => {});
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Tool call denied by user." }],
|
||||
};
|
||||
}
|
||||
await post("/proxy/ui/consent", { approved: true });
|
||||
if (CACHE_TOOL_CONSENT) {
|
||||
consentGranted = true;
|
||||
}
|
||||
}
|
||||
const result = await post("/proxy/tools/call", params);
|
||||
// Notify agent about the tool call
|
||||
await post("/proxy/ui/message", {
|
||||
type: "intent",
|
||||
intent: "call_tool",
|
||||
params: { tool: params.name, arguments: params.arguments, isError: result.isError }
|
||||
}).catch(() => {});
|
||||
return result;
|
||||
};
|
||||
|
||||
bridge.onmessage = async (params) => post("/proxy/ui/message", params);
|
||||
bridge.onupdatemodelcontext = async (params) => post("/proxy/ui/context", params);
|
||||
|
||||
// Also listen for raw postMessage events with custom types (notify, prompt, intent, etc.)
|
||||
// These bypass the AppBridge protocol but are used by some MCP UI implementations
|
||||
window.addEventListener("message", async (event) => {
|
||||
const data = event.data;
|
||||
if (!data || typeof data !== "object") return;
|
||||
|
||||
// Skip AppBridge protocol messages (handled by bridge)
|
||||
if (data.jsonrpc || (typeof data.method === "string" && (data.method.startsWith("app/") || data.method.startsWith("host/")))) return;
|
||||
|
||||
// Handle raw UI action messages
|
||||
const msgType = data.type;
|
||||
if (typeof msgType !== "string") return;
|
||||
|
||||
if (msgType === "notify" || msgType === "prompt" || msgType === "intent" || msgType === "message") {
|
||||
// Standard MCP-UI types - preserve their semantics
|
||||
// Support both { type, payload: {...} } and { type, field: value } formats
|
||||
const { type: _, payload, ...directFields } = data;
|
||||
await post("/proxy/ui/message", { type: msgType, ...directFields, ...(payload || {}) }).catch(() => {});
|
||||
} else if (!msgType.startsWith("ui-lifecycle-") && !msgType.startsWith("ui-message-")) {
|
||||
// Any other custom type - forward as intent with type as intent name
|
||||
// (Skip internal lifecycle/ack messages)
|
||||
const payload = data.payload || {};
|
||||
await post("/proxy/ui/message", {
|
||||
type: "intent",
|
||||
intent: msgType,
|
||||
params: payload,
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
bridge.ondownloadfile = async (params) => post("/proxy/ui/download-file", params);
|
||||
bridge.onrequestdisplaymode = async (params) => post("/proxy/ui/request-display-mode", params);
|
||||
bridge.onopenlink = async (params) => {
|
||||
const result = await post("/proxy/ui/open-link", params);
|
||||
if (!result.isError) {
|
||||
window.open(params.url, "_blank", "noopener,noreferrer");
|
||||
// Notify agent about the link open
|
||||
await post("/proxy/ui/message", {
|
||||
type: "intent",
|
||||
intent: "open_link",
|
||||
params: { url: params.url }
|
||||
}).catch(() => {});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
bridge.oninitialized = () => {
|
||||
if (streamMode !== "stream-first") {
|
||||
bridge.sendToolInput({ arguments: TOOL_ARGS });
|
||||
}
|
||||
setStatus(streamMode === "stream-first" ? "Streaming…" : "Connected");
|
||||
};
|
||||
|
||||
bridge.onsizechange = ({ width, height }) => {
|
||||
if (typeof width === "number" && width > 0) {
|
||||
iframe.style.minWidth = Math.min(width, window.innerWidth - 24) + "px";
|
||||
}
|
||||
if (typeof height === "number" && height > 0) {
|
||||
iframe.style.height = Math.max(height, 320) + "px";
|
||||
}
|
||||
};
|
||||
|
||||
if (ALLOW_ATTRIBUTE) {
|
||||
iframe.setAttribute("allow", ALLOW_ATTRIBUTE);
|
||||
}
|
||||
|
||||
// Connect bridge BEFORE loading iframe to ensure we're listening when the app sends ui/initialize
|
||||
try {
|
||||
const transport = new PostMessageTransport(iframe.contentWindow, null);
|
||||
await bridge.connect(transport);
|
||||
} catch (error) {
|
||||
console.error("[host] Bridge connection failed:", error);
|
||||
showError("Failed to initialize AppBridge: " + String(error));
|
||||
}
|
||||
|
||||
const iframeLoaded = new Promise((resolve) => {
|
||||
iframe.onload = resolve;
|
||||
});
|
||||
iframe.src = "/ui-app?session=" + encodeURIComponent(SESSION_TOKEN);
|
||||
await iframeLoaded;
|
||||
|
||||
const eventSource = new EventSource("/events?session=" + encodeURIComponent(SESSION_TOKEN));
|
||||
eventSource.addEventListener("tool-input", (event) => {
|
||||
try {
|
||||
bridge.sendToolInput(JSON.parse(event.data));
|
||||
} catch (error) {
|
||||
showError("Failed to forward tool input: " + String(error));
|
||||
}
|
||||
});
|
||||
eventSource.addEventListener("tool-result", (event) => {
|
||||
try {
|
||||
bridge.sendToolResult(JSON.parse(event.data));
|
||||
} catch (error) {
|
||||
showError("Failed to forward tool result: " + String(error));
|
||||
}
|
||||
});
|
||||
eventSource.addEventListener("tool-cancelled", (event) => {
|
||||
try {
|
||||
bridge.sendToolCancelled(JSON.parse(event.data));
|
||||
} catch (error) {
|
||||
showError("Failed to forward cancellation: " + String(error));
|
||||
}
|
||||
});
|
||||
eventSource.addEventListener("result-patch", async (event) => {
|
||||
try {
|
||||
await bridge.notification({
|
||||
method: STREAM_PATCH_METHOD,
|
||||
params: JSON.parse(event.data),
|
||||
});
|
||||
} catch (error) {
|
||||
showError("Failed to forward stream patch: " + String(error));
|
||||
}
|
||||
});
|
||||
eventSource.addEventListener("host-context", (event) => {
|
||||
try {
|
||||
bridge.setHostContext(JSON.parse(event.data));
|
||||
} catch {}
|
||||
});
|
||||
eventSource.addEventListener("session-complete", async () => {
|
||||
await bridge.teardownResource({}).catch(() => {});
|
||||
eventSource.close();
|
||||
window.close();
|
||||
});
|
||||
eventSource.onerror = () => {
|
||||
setStatus("Connection lost", true);
|
||||
};
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
post("/proxy/ui/heartbeat", {}).catch(() => {});
|
||||
}, 10000);
|
||||
|
||||
const complete = async (reason) => {
|
||||
try {
|
||||
await post("/proxy/ui/complete", { reason });
|
||||
} catch {}
|
||||
try {
|
||||
await bridge.teardownResource({});
|
||||
} catch {}
|
||||
clearInterval(heartbeat);
|
||||
eventSource.close();
|
||||
window.close();
|
||||
};
|
||||
|
||||
doneBtn.addEventListener("click", () => complete("done"));
|
||||
cancelBtn.addEventListener("click", () => complete("cancel"));
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
complete("cancel");
|
||||
} else if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
complete("done");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function buildCspMetaContent(csp: UiResourceCsp | undefined): string | undefined {
|
||||
if (!csp) return undefined;
|
||||
|
||||
const directives: string[] = [];
|
||||
directives.push("default-src 'none'");
|
||||
|
||||
const scriptSrc = toDirective("script-src", csp.scriptDomains);
|
||||
const styleSrc = toDirective("style-src", csp.styleDomains);
|
||||
const fontSrc = toDirective("font-src", csp.fontDomains);
|
||||
const imgSrc = toDirective("img-src", csp.imgDomains);
|
||||
const mediaSrc = toDirective("media-src", csp.mediaDomains);
|
||||
const connectSrc = toDirective("connect-src", csp.connectDomains);
|
||||
const frameSrc = toDirective("frame-src", csp.frameDomains);
|
||||
const workerSrc = toDirective("worker-src", csp.workerDomains);
|
||||
const baseUri = toDirective("base-uri", csp.baseUriDomains);
|
||||
|
||||
if (scriptSrc) directives.push(scriptSrc);
|
||||
if (styleSrc) directives.push(styleSrc);
|
||||
if (fontSrc) directives.push(fontSrc);
|
||||
if (imgSrc) directives.push(imgSrc);
|
||||
if (mediaSrc) directives.push(mediaSrc);
|
||||
if (connectSrc) directives.push(connectSrc);
|
||||
if (frameSrc) directives.push(frameSrc);
|
||||
if (workerSrc) directives.push(workerSrc);
|
||||
if (baseUri) directives.push(baseUri);
|
||||
|
||||
return directives.join("; ");
|
||||
}
|
||||
|
||||
function toDirective(name: string, domains: string[] | undefined): string | null {
|
||||
if (!domains || domains.length === 0) return null;
|
||||
return `${name} ${domains.join(" ")}`;
|
||||
}
|
||||
|
||||
export function applyCspMeta(html: string, cspContent: string | undefined): string {
|
||||
if (!cspContent) return html;
|
||||
if (/http-equiv=["']Content-Security-Policy["']/i.test(html)) return html;
|
||||
const metaTag = `<meta http-equiv="Content-Security-Policy" content="${escapeHtmlAttribute(cspContent)}">`;
|
||||
if (/<head[^>]*>/i.test(html)) {
|
||||
return html.replace(/<head[^>]*>/i, (match) => `${match}\n${metaTag}`);
|
||||
}
|
||||
return `${metaTag}\n${html}`;
|
||||
}
|
||||
|
||||
function safeInlineJSON(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
.replace(/</g, "\\u003c")
|
||||
.replace(/>/g, "\\u003e")
|
||||
.replace(/&/g, "\\u0026")
|
||||
.replace(/\u2028/g, "\\u2028")
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
361
agent/extensions/pi-mcp-adapter/index.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import type { ExtensionAPI, ToolInfo } from "@earendil-works/pi-coding-agent";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import { Type } from "typebox";
|
||||
import { showStatus, showTools, reconnectServers, authenticateServer, logoutServer, openMcpAuthPanel, openMcpPanel, openMcpSetup } from "./commands.ts";
|
||||
import { loadMcpConfig } from "./config.ts";
|
||||
import { buildProxyDescription, createDirectToolExecutor, getMissingConfiguredDirectToolServers, resolveDirectTools } from "./direct-tools.ts";
|
||||
import { flushMetadataCache, initializeMcp, updateStatusBar } from "./init.ts";
|
||||
import { loadMetadataCache } from "./metadata-cache.ts";
|
||||
import { executeAuthComplete, executeAuthStart, executeCall, executeConnect, executeDescribe, executeList, executeSearch, executeStatus, executeUiMessages } from "./proxy-modes.ts";
|
||||
import { getConfigPathFromArgv, truncateAtWord } from "./utils.ts";
|
||||
import { initializeOAuth, shutdownOAuth } from "./mcp-auth-flow.ts";
|
||||
import { createMcpDirectToolCallRenderer, renderMcpProxyToolCall, renderMcpToolResult } from "./tool-result-renderer.ts";
|
||||
|
||||
export default function mcpAdapter(pi: ExtensionAPI) {
|
||||
let state: McpExtensionState | null = null;
|
||||
let initPromise: Promise<McpExtensionState> | null = null;
|
||||
let lifecycleGeneration = 0;
|
||||
|
||||
async function shutdownState(currentState: McpExtensionState | null, reason: string): Promise<void> {
|
||||
if (!currentState) return;
|
||||
|
||||
if (currentState.uiServer) {
|
||||
currentState.uiServer.close(reason);
|
||||
currentState.uiServer = null;
|
||||
}
|
||||
|
||||
let flushError: unknown;
|
||||
try {
|
||||
flushMetadataCache(currentState);
|
||||
} catch (error) {
|
||||
flushError = error;
|
||||
}
|
||||
|
||||
try {
|
||||
await currentState.lifecycle.gracefulShutdown();
|
||||
} catch (error) {
|
||||
if (flushError) {
|
||||
console.error("MCP: graceful shutdown failed after metadata flush error", error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (flushError) {
|
||||
throw flushError;
|
||||
}
|
||||
}
|
||||
|
||||
const earlyConfigPath = getConfigPathFromArgv();
|
||||
const earlyConfig = loadMcpConfig(earlyConfigPath);
|
||||
const earlyCache = loadMetadataCache();
|
||||
const prefix = earlyConfig.settings?.toolPrefix ?? "server";
|
||||
|
||||
const envRaw = process.env.MCP_DIRECT_TOOLS;
|
||||
const directSpecs = envRaw === "__none__"
|
||||
? []
|
||||
: resolveDirectTools(
|
||||
earlyConfig,
|
||||
earlyCache,
|
||||
prefix,
|
||||
envRaw?.split(",").map(s => s.trim()).filter(Boolean),
|
||||
);
|
||||
const missingConfiguredDirectToolServers = getMissingConfiguredDirectToolServers(earlyConfig, earlyCache);
|
||||
const shouldRegisterProxyTool =
|
||||
earlyConfig.settings?.disableProxyTool !== true
|
||||
|| directSpecs.length === 0
|
||||
|| missingConfiguredDirectToolServers.length > 0;
|
||||
|
||||
for (const spec of directSpecs) {
|
||||
(pi.registerTool as (tool: unknown) => unknown)({
|
||||
name: spec.prefixedName,
|
||||
label: `MCP: ${spec.originalName}`,
|
||||
description: spec.description || "(no description)",
|
||||
promptSnippet: truncateAtWord(spec.description, 100) || `MCP tool from ${spec.serverName}`,
|
||||
parameters: Type.Unsafe((spec.inputSchema || { type: "object", properties: {} }) as never),
|
||||
execute: createDirectToolExecutor(() => state, () => initPromise, spec),
|
||||
renderCall: createMcpDirectToolCallRenderer(spec.prefixedName),
|
||||
renderResult: renderMcpToolResult,
|
||||
});
|
||||
}
|
||||
|
||||
const getPiTools = (): ToolInfo[] => pi.getAllTools();
|
||||
|
||||
pi.registerFlag("mcp-config", {
|
||||
description: "Path to MCP config file",
|
||||
type: "string",
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const generation = ++lifecycleGeneration;
|
||||
const previousState = state;
|
||||
state = null;
|
||||
initPromise = null;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
shutdownState(previousState, "session_restart"),
|
||||
shutdownOAuth(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("MCP: failed to shut down previous session state", error);
|
||||
}
|
||||
|
||||
if (generation !== lifecycleGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
await initializeOAuth().catch(err => {
|
||||
console.error("MCP OAuth initialization failed:", err);
|
||||
});
|
||||
|
||||
const promise = initializeMcp(pi, ctx);
|
||||
initPromise = promise;
|
||||
|
||||
promise.then(async (nextState) => {
|
||||
if (generation !== lifecycleGeneration || initPromise !== promise) {
|
||||
try {
|
||||
await shutdownState(nextState, "stale_session_start");
|
||||
} catch (error) {
|
||||
console.error("MCP: failed to clean stale session state", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
state = nextState;
|
||||
updateStatusBar(nextState);
|
||||
initPromise = null;
|
||||
}).catch(err => {
|
||||
if (generation !== lifecycleGeneration) {
|
||||
return;
|
||||
}
|
||||
if (initPromise !== promise && initPromise !== null) {
|
||||
return;
|
||||
}
|
||||
console.error("MCP initialization failed:", err);
|
||||
initPromise = null;
|
||||
});
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
++lifecycleGeneration;
|
||||
const currentState = state;
|
||||
state = null;
|
||||
initPromise = null;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
shutdownState(currentState, "session_shutdown"),
|
||||
shutdownOAuth(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("MCP: session shutdown cleanup failed", error);
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand("mcp", {
|
||||
description: "Show MCP server status",
|
||||
handler: async (args, ctx) => {
|
||||
if (!state && initPromise) {
|
||||
try {
|
||||
state = await initPromise;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (ctx.hasUI) ctx.ui.notify(`MCP initialization failed: ${message}`, "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!state) {
|
||||
if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = args?.trim()?.split(/\s+/) ?? [];
|
||||
const subcommand = parts[0] ?? "";
|
||||
const targetServer = parts[1];
|
||||
const rest = parts.slice(1).join(" ");
|
||||
|
||||
switch (subcommand) {
|
||||
case "reconnect":
|
||||
await reconnectServers(state, ctx, targetServer);
|
||||
break;
|
||||
case "tools":
|
||||
await showTools(state, ctx);
|
||||
break;
|
||||
case "setup": {
|
||||
const result = await openMcpSetup(state, pi, ctx, earlyConfigPath, "setup");
|
||||
if (result?.configChanged) {
|
||||
await ctx.reload();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "logout": {
|
||||
const serverName = rest;
|
||||
if (!serverName) {
|
||||
if (ctx.hasUI) ctx.ui.notify("Usage: /mcp logout <server>", "error");
|
||||
return;
|
||||
}
|
||||
await logoutServer(serverName, state, ctx);
|
||||
break;
|
||||
}
|
||||
case "status":
|
||||
case "":
|
||||
default:
|
||||
if (ctx.hasUI) {
|
||||
const result = await openMcpPanel(state, pi, ctx, earlyConfigPath);
|
||||
if (result?.configChanged) {
|
||||
await ctx.reload();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await showStatus(state, ctx);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("mcp-auth", {
|
||||
description: "Authenticate with an MCP server (OAuth)",
|
||||
handler: async (args, ctx) => {
|
||||
const serverName = args?.trim();
|
||||
if (!serverName && !ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state && initPromise) {
|
||||
try {
|
||||
state = await initPromise;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (ctx.hasUI) ctx.ui.notify(`MCP initialization failed: ${message}`, "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!state) {
|
||||
if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!serverName) {
|
||||
await openMcpAuthPanel(state, pi, ctx, earlyConfigPath);
|
||||
return;
|
||||
}
|
||||
|
||||
await authenticateServer(serverName, state.config, ctx);
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldRegisterProxyTool) {
|
||||
(pi.registerTool as (tool: unknown) => unknown)({
|
||||
name: "mcp",
|
||||
label: "MCP",
|
||||
description: buildProxyDescription(earlyConfig, earlyCache, directSpecs),
|
||||
promptSnippet: "MCP gateway - connect to MCP servers and call their tools",
|
||||
renderCall: renderMcpProxyToolCall,
|
||||
parameters: Type.Object({
|
||||
tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
|
||||
args: Type.Optional(Type.String({ description: "Arguments as JSON string (e.g., '{\"key\": \"value\"}')" })),
|
||||
connect: Type.Optional(Type.String({ description: "Server name to connect (lazy connect + metadata refresh)" })),
|
||||
describe: Type.Optional(Type.String({ description: "Tool name to describe (shows parameters)" })),
|
||||
search: Type.Optional(Type.String({ description: "Search tools by name/description" })),
|
||||
regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })),
|
||||
includeSchemas: Type.Optional(Type.Boolean({ description: "Include parameter schemas in search results (default: true)" })),
|
||||
server: Type.Optional(Type.String({ description: "Filter to specific server (also disambiguates tool calls)" })),
|
||||
action: Type.Optional(Type.String({ description: "Action: 'ui-messages', 'auth-start', or 'auth-complete'" })),
|
||||
}),
|
||||
renderResult: renderMcpToolResult,
|
||||
async execute(_toolCallId, params: {
|
||||
tool?: string;
|
||||
args?: string;
|
||||
connect?: string;
|
||||
describe?: string;
|
||||
search?: string;
|
||||
regex?: boolean;
|
||||
includeSchemas?: boolean;
|
||||
server?: string;
|
||||
action?: string;
|
||||
}, _signal, _onUpdate, _ctx) {
|
||||
let parsedArgs: Record<string, unknown> | undefined;
|
||||
if (params.args) {
|
||||
try {
|
||||
parsedArgs = JSON.parse(params.args);
|
||||
if (typeof parsedArgs !== "object" || parsedArgs === null || Array.isArray(parsedArgs)) {
|
||||
const gotType = Array.isArray(parsedArgs) ? "array" : parsedArgs === null ? "null" : typeof parsedArgs;
|
||||
throw new Error(`Invalid args: expected a JSON object, got ${gotType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Invalid args JSON: ${error.message}`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!state && initPromise) {
|
||||
try {
|
||||
state = await initPromise;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }],
|
||||
details: { error: "init_failed", message },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!state) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "MCP not initialized" }],
|
||||
details: { error: "not_initialized" },
|
||||
};
|
||||
}
|
||||
|
||||
if (params.action === "ui-messages") {
|
||||
return executeUiMessages(state);
|
||||
}
|
||||
if (params.action === "auth-start") {
|
||||
if (!params.server) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "auth-start requires `server`. Example: mcp({ action: \"auth-start\", server: \"linear-server\" })" }],
|
||||
details: { mode: "auth-start", error: "missing_server" },
|
||||
};
|
||||
}
|
||||
return executeAuthStart(state, params.server);
|
||||
}
|
||||
if (params.action === "auth-complete") {
|
||||
if (!params.server) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "auth-complete requires `server`." }],
|
||||
details: { mode: "auth-complete", error: "missing_server" },
|
||||
};
|
||||
}
|
||||
const input = parsedArgs?.redirectUrl ?? parsedArgs?.code ?? parsedArgs?.input;
|
||||
if (typeof input !== "string" || input.trim().length === 0) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "auth-complete requires args with `redirectUrl`, `code`, or `input`." }],
|
||||
details: { mode: "auth-complete", error: "missing_input" },
|
||||
};
|
||||
}
|
||||
return executeAuthComplete(state, params.server, input);
|
||||
}
|
||||
if (params.tool) {
|
||||
return executeCall(state, params.tool, parsedArgs, params.server, getPiTools);
|
||||
}
|
||||
if (params.connect) {
|
||||
return executeConnect(state, params.connect);
|
||||
}
|
||||
if (params.describe) {
|
||||
return executeDescribe(state, params.describe);
|
||||
}
|
||||
if (params.search) {
|
||||
return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas);
|
||||
}
|
||||
if (params.server) {
|
||||
return executeList(state, params.server);
|
||||
}
|
||||
return executeStatus(state);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
347
agent/extensions/pi-mcp-adapter/init.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import type { ToolMetadata } from "./types.ts";
|
||||
import { existsSync } from "node:fs";
|
||||
import { loadMcpConfig } from "./config.ts";
|
||||
import { ConsentManager } from "./consent-manager.ts";
|
||||
import { McpLifecycleManager } from "./lifecycle.ts";
|
||||
import {
|
||||
computeServerHash,
|
||||
getMetadataCachePath,
|
||||
isServerCacheValid,
|
||||
loadMetadataCache,
|
||||
reconstructToolMetadata,
|
||||
saveMetadataCache,
|
||||
serializeResources,
|
||||
serializeTools,
|
||||
type ServerCacheEntry,
|
||||
} from "./metadata-cache.ts";
|
||||
import { McpServerManager } from "./server-manager.ts";
|
||||
import { buildToolMetadata, totalToolCount } from "./tool-metadata.ts";
|
||||
import { UiResourceHandler } from "./ui-resource-handler.ts";
|
||||
import { openUrl, parallelLimit } from "./utils.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { getMissingConfiguredDirectToolServers } from "./direct-tools.ts";
|
||||
|
||||
const FAILURE_BACKOFF_MS = 60 * 1000;
|
||||
|
||||
export function isTuiMode(ctx: Pick<ExtensionContext, "hasUI" | "mode">): boolean {
|
||||
return ctx.hasUI && ctx.mode === "tui";
|
||||
}
|
||||
|
||||
export async function initializeMcp(
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext
|
||||
): Promise<McpExtensionState> {
|
||||
const configPath = pi.getFlag("mcp-config") as string | undefined;
|
||||
const config = loadMcpConfig(configPath, ctx.cwd);
|
||||
|
||||
const manager = new McpServerManager();
|
||||
const samplingAutoApprove = config.settings?.samplingAutoApprove === true;
|
||||
if (config.settings?.sampling !== false && (ctx.hasUI || samplingAutoApprove)) {
|
||||
manager.setSamplingConfig({
|
||||
autoApprove: samplingAutoApprove,
|
||||
ui: ctx.hasUI ? ctx.ui : undefined,
|
||||
modelRegistry: ctx.modelRegistry,
|
||||
getCurrentModel: () => ctx.model,
|
||||
getSignal: () => ctx.signal,
|
||||
});
|
||||
}
|
||||
const elicitationEnabled = config.settings?.elicitation !== false && ctx.hasUI;
|
||||
if (elicitationEnabled) {
|
||||
manager.setElicitationConfig({
|
||||
ui: ctx.ui,
|
||||
allowUrl: isTuiMode(ctx),
|
||||
});
|
||||
}
|
||||
const lifecycle = new McpLifecycleManager(manager);
|
||||
const toolMetadata = new Map<string, ToolMetadata[]>();
|
||||
const failureTracker = new Map<string, number>();
|
||||
const uiResourceHandler = new UiResourceHandler(manager);
|
||||
const consentManager = new ConsentManager("once-per-server");
|
||||
const ui = ctx.hasUI ? ctx.ui : undefined;
|
||||
const state: McpExtensionState = {
|
||||
manager,
|
||||
lifecycle,
|
||||
toolMetadata,
|
||||
config,
|
||||
failureTracker,
|
||||
uiResourceHandler,
|
||||
consentManager,
|
||||
uiServer: null,
|
||||
completedUiSessions: [],
|
||||
openBrowser: (url: string) => openUrl(pi, url, process.env.BROWSER),
|
||||
ui,
|
||||
sendMessage: (message, options) => pi.sendMessage(message as unknown as Parameters<typeof pi.sendMessage>[0], options),
|
||||
};
|
||||
|
||||
const serverEntries = Object.entries(config.mcpServers);
|
||||
if (serverEntries.length === 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idleSetting = typeof config.settings?.idleTimeout === "number" ? config.settings.idleTimeout : 10;
|
||||
lifecycle.setGlobalIdleTimeout(idleSetting);
|
||||
|
||||
const cachePath = getMetadataCachePath();
|
||||
const cacheFileExists = existsSync(cachePath);
|
||||
let cache = loadMetadataCache();
|
||||
let bootstrapAll = false;
|
||||
|
||||
if (!cacheFileExists) {
|
||||
bootstrapAll = true;
|
||||
saveMetadataCache({ version: 1, servers: {} });
|
||||
} else if (!cache) {
|
||||
cache = { version: 1, servers: {} };
|
||||
saveMetadataCache(cache);
|
||||
}
|
||||
|
||||
const prefix = config.settings?.toolPrefix ?? "server";
|
||||
|
||||
for (const [name, definition] of serverEntries) {
|
||||
const lifecycleMode = definition.lifecycle ?? "lazy";
|
||||
const idleOverride = definition.idleTimeout ?? (lifecycleMode === "eager" ? 0 : undefined);
|
||||
lifecycle.registerServer(
|
||||
name,
|
||||
definition,
|
||||
idleOverride !== undefined ? { idleTimeout: idleOverride } : undefined
|
||||
);
|
||||
if (lifecycleMode === "keep-alive") {
|
||||
lifecycle.markKeepAlive(name, definition);
|
||||
}
|
||||
|
||||
if (cache?.servers?.[name] && isServerCacheValid(cache.servers[name], definition)) {
|
||||
const metadata = reconstructToolMetadata(name, cache.servers[name], prefix, definition);
|
||||
toolMetadata.set(name, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
const startupServers = bootstrapAll
|
||||
? serverEntries
|
||||
: serverEntries.filter(([, definition]) => {
|
||||
const mode = definition.lifecycle ?? "lazy";
|
||||
return mode === "keep-alive" || mode === "eager";
|
||||
});
|
||||
|
||||
if (ctx.hasUI && startupServers.length > 0) {
|
||||
ctx.ui.setStatus("mcp", `MCP: connecting to ${startupServers.length} servers...`);
|
||||
}
|
||||
|
||||
const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
|
||||
try {
|
||||
const connection = await manager.connect(name, definition);
|
||||
if (connection.status === "needs-auth") {
|
||||
return { name, definition, connection: null, error: `OAuth authentication required. Run /mcp-auth ${name}.` };
|
||||
}
|
||||
return { name, definition, connection, error: null };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { name, definition, connection: null, error: message };
|
||||
}
|
||||
});
|
||||
|
||||
for (const { name, definition, connection, error } of results) {
|
||||
if (error || !connection) {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(`MCP: Failed to connect to ${name}: ${error}`, "error");
|
||||
}
|
||||
console.error(`MCP: Failed to connect to ${name}: ${error}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
||||
toolMetadata.set(name, metadata);
|
||||
updateMetadataCache(state, name);
|
||||
|
||||
if (failedTools.length > 0 && ctx.hasUI) {
|
||||
ctx.ui.notify(
|
||||
`MCP: ${name} - ${failedTools.length} tools skipped`,
|
||||
"warning"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const connectedCount = results.filter(r => r.connection).length;
|
||||
const failedCount = results.filter(r => r.error).length;
|
||||
if (ctx.hasUI && connectedCount > 0) {
|
||||
const totalTools = totalToolCount(state);
|
||||
const msg = failedCount > 0
|
||||
? `MCP: ${connectedCount}/${startupServers.length} servers connected (${totalTools} tools)`
|
||||
: `MCP: ${connectedCount} servers connected (${totalTools} tools)`;
|
||||
ctx.ui.notify(msg, "info");
|
||||
}
|
||||
|
||||
const envDirect = process.env.MCP_DIRECT_TOOLS;
|
||||
if (envDirect !== "__none__") {
|
||||
const currentCache = loadMetadataCache();
|
||||
const missingCacheServers = getMissingConfiguredDirectToolServers(config, currentCache);
|
||||
|
||||
if (missingCacheServers.length > 0) {
|
||||
const bootstrapResults = await parallelLimit(
|
||||
missingCacheServers.filter(name => !results.some(r => r.name === name && r.connection)),
|
||||
10,
|
||||
async (name) => {
|
||||
const definition = config.mcpServers[name];
|
||||
try {
|
||||
const connection = await manager.connect(name, definition);
|
||||
if (connection.status === "needs-auth") {
|
||||
return { name, ok: false };
|
||||
}
|
||||
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
||||
toolMetadata.set(name, metadata);
|
||||
updateMetadataCache(state, name);
|
||||
return { name, ok: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.debug(`MCP: direct-tools bootstrap failed for ${name}: ${message}`);
|
||||
return { name, ok: false };
|
||||
}
|
||||
},
|
||||
);
|
||||
const bootstrapped = bootstrapResults.filter(r => r.ok).map(r => r.name);
|
||||
if (bootstrapped.length > 0 && ctx.hasUI) {
|
||||
ctx.ui.notify(`MCP: direct tools for ${bootstrapped.join(", ")} will be available after restart`, "info");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lifecycle.setReconnectCallback((serverName) => {
|
||||
updateServerMetadata(state, serverName);
|
||||
updateMetadataCache(state, serverName);
|
||||
state.failureTracker.delete(serverName);
|
||||
updateStatusBar(state);
|
||||
});
|
||||
|
||||
lifecycle.setIdleShutdownCallback((serverName) => {
|
||||
const idleMinutes = getEffectiveIdleTimeoutMinutes(state, serverName);
|
||||
logger.debug(`${serverName} shut down (idle ${idleMinutes}m)`);
|
||||
updateStatusBar(state);
|
||||
});
|
||||
|
||||
lifecycle.startHealthChecks();
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function updateServerMetadata(state: McpExtensionState, serverName: string): void {
|
||||
const connection = state.manager.getConnection(serverName);
|
||||
if (!connection || connection.status !== "connected") return;
|
||||
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) return;
|
||||
|
||||
const prefix = state.config.settings?.toolPrefix ?? "server";
|
||||
|
||||
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
|
||||
state.toolMetadata.set(serverName, metadata);
|
||||
}
|
||||
|
||||
export function updateMetadataCache(state: McpExtensionState, serverName: string): void {
|
||||
const connection = state.manager.getConnection(serverName);
|
||||
if (!connection || connection.status !== "connected") return;
|
||||
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) return;
|
||||
|
||||
const configHash = computeServerHash(definition);
|
||||
const existing = loadMetadataCache();
|
||||
const existingEntry = existing?.servers?.[serverName];
|
||||
|
||||
const tools = serializeTools(connection.tools);
|
||||
let resources = definition.exposeResources === false ? [] : serializeResources(connection.resources);
|
||||
|
||||
if (
|
||||
definition.exposeResources !== false &&
|
||||
resources.length === 0 &&
|
||||
existingEntry?.resources?.length &&
|
||||
existingEntry.configHash === configHash
|
||||
) {
|
||||
resources = existingEntry.resources;
|
||||
}
|
||||
|
||||
const entry: ServerCacheEntry = {
|
||||
configHash,
|
||||
tools,
|
||||
resources,
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
|
||||
saveMetadataCache({ version: 1, servers: { [serverName]: entry } });
|
||||
}
|
||||
|
||||
export function flushMetadataCache(state: McpExtensionState): void {
|
||||
for (const [name, connection] of state.manager.getAllConnections()) {
|
||||
if (connection.status === "connected") {
|
||||
updateMetadataCache(state, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateStatusBar(state: McpExtensionState): void {
|
||||
const ui = state.ui;
|
||||
if (!ui) return;
|
||||
const total = Object.keys(state.config.mcpServers).length;
|
||||
if (total === 0) {
|
||||
ui.setStatus("mcp", undefined);
|
||||
return;
|
||||
}
|
||||
const connectedCount = state.manager.getAllConnections().size;
|
||||
ui.setStatus("mcp", ui.theme.fg("accent", `MCP: ${connectedCount}/${total} servers`));
|
||||
}
|
||||
|
||||
export function getFailureAgeSeconds(state: McpExtensionState, serverName: string): number | null {
|
||||
const failedAt = state.failureTracker.get(serverName);
|
||||
if (!failedAt) return null;
|
||||
const ageMs = Date.now() - failedAt;
|
||||
if (ageMs > FAILURE_BACKOFF_MS) return null;
|
||||
return Math.round(ageMs / 1000);
|
||||
}
|
||||
|
||||
export async function lazyConnect(state: McpExtensionState, serverName: string): Promise<boolean> {
|
||||
const connection = state.manager.getConnection(serverName);
|
||||
if (connection?.status === "needs-auth") {
|
||||
return false;
|
||||
}
|
||||
if (connection?.status === "connected") {
|
||||
updateServerMetadata(state, serverName);
|
||||
return true;
|
||||
}
|
||||
|
||||
const failedAgo = getFailureAgeSeconds(state, serverName);
|
||||
if (failedAgo !== null) return false;
|
||||
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) return false;
|
||||
|
||||
try {
|
||||
if (state.ui) {
|
||||
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
||||
}
|
||||
const newConnection = await state.manager.connect(serverName, definition);
|
||||
if (newConnection.status === "needs-auth") {
|
||||
return false;
|
||||
}
|
||||
state.failureTracker.delete(serverName);
|
||||
updateServerMetadata(state, serverName);
|
||||
updateMetadataCache(state, serverName);
|
||||
updateStatusBar(state);
|
||||
return true;
|
||||
} catch (error) {
|
||||
state.failureTracker.set(serverName, Date.now());
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.debug(`MCP: lazy connect failed for ${serverName}: ${message}`);
|
||||
updateStatusBar(state);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getEffectiveIdleTimeoutMinutes(state: McpExtensionState, serverName: string): number {
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) {
|
||||
return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
|
||||
}
|
||||
if (typeof definition.idleTimeout === "number") return definition.idleTimeout;
|
||||
const mode = definition.lifecycle ?? "lazy";
|
||||
if (mode === "eager") return 0;
|
||||
return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
|
||||
}
|
||||
93
agent/extensions/pi-mcp-adapter/lifecycle.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { ServerDefinition } from "./types.ts";
|
||||
import type { McpServerManager } from "./server-manager.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
|
||||
export type ReconnectCallback = (serverName: string) => void;
|
||||
|
||||
export class McpLifecycleManager {
|
||||
private manager: McpServerManager;
|
||||
private keepAliveServers = new Map<string, ServerDefinition>();
|
||||
private allServers = new Map<string, ServerDefinition>();
|
||||
private serverSettings = new Map<string, { idleTimeout?: number }>();
|
||||
private globalIdleTimeout: number = 10 * 60 * 1000;
|
||||
private healthCheckInterval?: NodeJS.Timeout;
|
||||
private onReconnect?: ReconnectCallback;
|
||||
private onIdleShutdown?: (serverName: string) => void;
|
||||
|
||||
constructor(manager: McpServerManager) {
|
||||
this.manager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback to be invoked after a successful auto-reconnect.
|
||||
* Use this to update tool metadata when a server reconnects.
|
||||
*/
|
||||
setReconnectCallback(callback: ReconnectCallback): void {
|
||||
this.onReconnect = callback;
|
||||
}
|
||||
|
||||
markKeepAlive(name: string, definition: ServerDefinition): void {
|
||||
this.keepAliveServers.set(name, definition);
|
||||
}
|
||||
|
||||
registerServer(name: string, definition: ServerDefinition, settings?: { idleTimeout?: number }): void {
|
||||
this.allServers.set(name, definition);
|
||||
if (settings?.idleTimeout !== undefined) {
|
||||
this.serverSettings.set(name, settings);
|
||||
}
|
||||
}
|
||||
|
||||
setGlobalIdleTimeout(minutes: number): void {
|
||||
this.globalIdleTimeout = minutes * 60 * 1000;
|
||||
}
|
||||
|
||||
setIdleShutdownCallback(callback: (serverName: string) => void): void {
|
||||
this.onIdleShutdown = callback;
|
||||
}
|
||||
|
||||
startHealthChecks(intervalMs = 30000): void {
|
||||
this.healthCheckInterval = setInterval(() => {
|
||||
this.checkConnections();
|
||||
}, intervalMs);
|
||||
this.healthCheckInterval.unref();
|
||||
}
|
||||
|
||||
private async checkConnections(): Promise<void> {
|
||||
for (const [name, definition] of this.keepAliveServers) {
|
||||
const connection = this.manager.getConnection(name);
|
||||
|
||||
if (!connection || connection.status !== "connected") {
|
||||
try {
|
||||
await this.manager.connect(name, definition);
|
||||
logger.debug(`Reconnected to ${name}`);
|
||||
// Notify extension to update metadata
|
||||
this.onReconnect?.(name);
|
||||
} catch (error) {
|
||||
console.error(`MCP: Failed to reconnect to ${name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name] of this.allServers) {
|
||||
if (this.keepAliveServers.has(name)) continue;
|
||||
const timeout = this.getIdleTimeout(name);
|
||||
if (timeout > 0 && this.manager.isIdle(name, timeout)) {
|
||||
await this.manager.close(name);
|
||||
this.onIdleShutdown?.(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getIdleTimeout(name: string): number {
|
||||
const perServer = this.serverSettings.get(name)?.idleTimeout;
|
||||
if (perServer !== undefined) return perServer * 60 * 1000;
|
||||
return this.globalIdleTimeout;
|
||||
}
|
||||
|
||||
async gracefulShutdown(): Promise<void> {
|
||||
if (this.healthCheckInterval) {
|
||||
clearInterval(this.healthCheckInterval);
|
||||
}
|
||||
await this.manager.closeAll();
|
||||
}
|
||||
}
|
||||
169
agent/extensions/pi-mcp-adapter/logger.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Centralized logging for MCP UI operations.
|
||||
* Provides structured, contextual logs with levels.
|
||||
*/
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface LogContext {
|
||||
server?: string;
|
||||
session?: string;
|
||||
tool?: string;
|
||||
uri?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
context?: LogContext;
|
||||
error?: Error;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
type LogHandler = (entry: LogEntry) => void;
|
||||
|
||||
const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
const LEVEL_PREFIX: Record<LogLevel, string> = {
|
||||
debug: "[MCP-UI:DEBUG]",
|
||||
info: "[MCP-UI]",
|
||||
warn: "[MCP-UI:WARN]",
|
||||
error: "[MCP-UI:ERROR]",
|
||||
};
|
||||
|
||||
class Logger {
|
||||
private minLevel: LogLevel = "info";
|
||||
private handlers: LogHandler[] = [];
|
||||
private defaultContext: LogContext = {};
|
||||
|
||||
setLevel(level: LogLevel): void {
|
||||
this.minLevel = level;
|
||||
}
|
||||
|
||||
setDefaultContext(context: LogContext): void {
|
||||
this.defaultContext = context;
|
||||
}
|
||||
|
||||
addHandler(handler: LogHandler): void {
|
||||
this.handlers.push(handler);
|
||||
}
|
||||
|
||||
clearHandlers(): void {
|
||||
this.handlers = [];
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[this.minLevel];
|
||||
}
|
||||
|
||||
private emit(level: LogLevel, message: string, context?: LogContext, error?: Error): void {
|
||||
if (!this.shouldLog(level)) return;
|
||||
|
||||
const entry: LogEntry = {
|
||||
level,
|
||||
message,
|
||||
context: { ...this.defaultContext, ...context },
|
||||
error,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// Default console output
|
||||
const prefix = LEVEL_PREFIX[level];
|
||||
const contextStr = formatContext(entry.context);
|
||||
const fullMessage = contextStr ? `${prefix} ${message} ${contextStr}` : `${prefix} ${message}`;
|
||||
|
||||
if (level === "error") {
|
||||
console.error(fullMessage, error ?? "");
|
||||
} else if (level === "warn") {
|
||||
console.warn(fullMessage);
|
||||
} else if (level === "debug") {
|
||||
console.debug(fullMessage);
|
||||
} else {
|
||||
console.log(fullMessage);
|
||||
}
|
||||
|
||||
// Custom handlers
|
||||
for (const handler of this.handlers) {
|
||||
try {
|
||||
handler(entry);
|
||||
} catch {
|
||||
// Ignore handler errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string, context?: LogContext): void {
|
||||
this.emit("debug", message, context);
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
this.emit("info", message, context);
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
this.emit("warn", message, context);
|
||||
}
|
||||
|
||||
error(message: string, error?: Error, context?: LogContext): void {
|
||||
this.emit("error", message, context, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger with additional default context.
|
||||
*/
|
||||
child(context: LogContext): ChildLogger {
|
||||
return new ChildLogger(this, context);
|
||||
}
|
||||
}
|
||||
|
||||
class ChildLogger {
|
||||
constructor(
|
||||
private parent: Logger,
|
||||
private context: LogContext
|
||||
) {}
|
||||
|
||||
debug(message: string, context?: LogContext): void {
|
||||
this.parent.debug(message, { ...this.context, ...context });
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
this.parent.info(message, { ...this.context, ...context });
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
this.parent.warn(message, { ...this.context, ...context });
|
||||
}
|
||||
|
||||
error(message: string, error?: Error, context?: LogContext): void {
|
||||
this.parent.error(message, error, { ...this.context, ...context });
|
||||
}
|
||||
|
||||
child(context: LogContext): ChildLogger {
|
||||
return new ChildLogger(this.parent, { ...this.context, ...context });
|
||||
}
|
||||
}
|
||||
|
||||
function formatContext(context?: LogContext): string {
|
||||
if (!context || Object.keys(context).length === 0) return "";
|
||||
const parts: string[] = [];
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
parts.push(`${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? `(${parts.join(", ")})` : "";
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const logger = new Logger();
|
||||
|
||||
// Enable debug mode via environment variable
|
||||
if (process.env.MCP_UI_DEBUG === "1" || process.env.MCP_UI_DEBUG === "true") {
|
||||
logger.setLevel("debug");
|
||||
}
|
||||
259
agent/extensions/pi-mcp-adapter/mcp-auth-flow.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Tests for mcp-auth-flow.ts - OAuth flow using MCP SDK
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { existsSync, rmSync, mkdirSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
// Set up isolated temp directory for tests
|
||||
const TEST_DIR = join(tmpdir(), `mcp-oauth-test-${randomBytes(4).toString('hex')}`)
|
||||
process.env.MCP_OAUTH_DIR = TEST_DIR
|
||||
|
||||
import {
|
||||
authenticate,
|
||||
startAuth,
|
||||
completeAuth,
|
||||
getAuthStatus,
|
||||
removeAuth,
|
||||
supportsOAuth,
|
||||
extractOAuthConfig,
|
||||
initializeOAuth,
|
||||
shutdownOAuth,
|
||||
type AuthStatus,
|
||||
} from "./mcp-auth-flow.ts"
|
||||
import { isCallbackServerRunning } from "./mcp-callback-server.ts"
|
||||
import { updateTokens, clearAllCredentials } from "./mcp-auth.ts"
|
||||
import type { ServerEntry } from "./types.ts"
|
||||
|
||||
describe("mcp-auth-flow", () => {
|
||||
before(() => {
|
||||
// Ensure clean state
|
||||
try {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
// Shutdown OAuth and clean up
|
||||
await shutdownOAuth()
|
||||
try {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
describe("supportsOAuth", () => {
|
||||
it("should return true for OAuth HTTP server", () => {
|
||||
const definition: ServerEntry = {
|
||||
url: "https://api.example.com/mcp",
|
||||
}
|
||||
assert.strictEqual(supportsOAuth(definition), true)
|
||||
})
|
||||
|
||||
it("should return false for bearer auth", () => {
|
||||
const definition: ServerEntry = {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "bearer",
|
||||
}
|
||||
assert.strictEqual(supportsOAuth(definition), false)
|
||||
})
|
||||
|
||||
it("should return false for stdio server", () => {
|
||||
const definition: ServerEntry = {
|
||||
command: "npx",
|
||||
args: ["-y", "@example/mcp-server"],
|
||||
}
|
||||
assert.strictEqual(supportsOAuth(definition), false)
|
||||
})
|
||||
|
||||
it("should return false when no URL", () => {
|
||||
const definition: ServerEntry = {}
|
||||
assert.strictEqual(supportsOAuth(definition), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAuthStatus", () => {
|
||||
it("should return 'not_authenticated' when no tokens", async () => {
|
||||
const status = await getAuthStatus("status-test-none")
|
||||
assert.strictEqual(status, "not_authenticated")
|
||||
})
|
||||
|
||||
it("should return 'authenticated' when tokens exist and not expired", async () => {
|
||||
await updateTokens("status-test-ok", {
|
||||
accessToken: "token",
|
||||
expiresAt: Date.now() / 1000 + 3600, // 1 hour from now
|
||||
})
|
||||
|
||||
const status = await getAuthStatus("status-test-ok")
|
||||
assert.strictEqual(status, "authenticated")
|
||||
})
|
||||
|
||||
it("should return 'expired' when tokens are expired", async () => {
|
||||
await updateTokens("status-test-expired", {
|
||||
accessToken: "token",
|
||||
expiresAt: Date.now() / 1000 - 3600, // 1 hour ago
|
||||
})
|
||||
|
||||
const status = await getAuthStatus("status-test-expired")
|
||||
assert.strictEqual(status, "expired")
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeAuth", () => {
|
||||
it("should remove all credentials", async () => {
|
||||
await updateTokens("remove-test", { accessToken: "token" })
|
||||
|
||||
await removeAuth("remove-test")
|
||||
|
||||
const status = await getAuthStatus("remove-test")
|
||||
assert.strictEqual(status, "not_authenticated")
|
||||
})
|
||||
})
|
||||
|
||||
describe("initializeOAuth / shutdownOAuth", () => {
|
||||
it("should not start callback server on initialize", async () => {
|
||||
await shutdownOAuth()
|
||||
await initializeOAuth()
|
||||
assert.strictEqual(isCallbackServerRunning(), false)
|
||||
})
|
||||
|
||||
it("should stop callback server on shutdown", async () => {
|
||||
await initializeOAuth()
|
||||
await shutdownOAuth()
|
||||
assert.strictEqual(isCallbackServerRunning(), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("authenticate / completeAuth", () => {
|
||||
it("should throw if no server URL provided", async () => {
|
||||
await assert.rejects(
|
||||
async () => await authenticate("no-url-test", ""),
|
||||
/Invalid URL/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject malformed OAuth redirectUri values", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("bad-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "not a url" },
|
||||
}),
|
||||
/Invalid OAuth redirectUri/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject non-local OAuth redirectUri values", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("remote-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "https://example.com:3118/callback" },
|
||||
}),
|
||||
/localhost or loopback/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject OAuth redirectUri values without an explicit port", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("no-port-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "http://localhost/callback" },
|
||||
}),
|
||||
/explicit numeric port/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject blank OAuth redirectUri values", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("blank-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: " " },
|
||||
}),
|
||||
/redirectUri must not be empty/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject non-string OAuth redirectUri values", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("typed-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: 3118 as unknown as string },
|
||||
}),
|
||||
/redirectUri must be a string/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject OAuth redirectUri values with fragments", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("fragment-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "http://localhost:3118/callback#fragment" },
|
||||
}),
|
||||
/redirectUri must not include a fragment/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject OAuth redirectUri values with username or password", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("credential-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "http://user:pass@localhost:3118/callback" },
|
||||
}),
|
||||
/redirectUri must not include username or password/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject non-string OAuth clientName and clientUri values", () => {
|
||||
assert.throws(
|
||||
() => extractOAuthConfig({
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { clientName: 123 as unknown as string },
|
||||
}),
|
||||
/clientName must be a string/
|
||||
)
|
||||
assert.throws(
|
||||
() => extractOAuthConfig({
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { clientUri: 123 as unknown as string },
|
||||
}),
|
||||
/clientUri must be a string/
|
||||
)
|
||||
})
|
||||
|
||||
it("should trim OAuth redirectUri and client metadata values", () => {
|
||||
const config = extractOAuthConfig({
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: {
|
||||
redirectUri: " http://localhost:3118/callback ",
|
||||
clientName: " Custom MCP ",
|
||||
clientUri: " https://example.com/custom ",
|
||||
},
|
||||
})
|
||||
|
||||
assert.strictEqual(config.redirectUri, "http://localhost:3118/callback")
|
||||
assert.strictEqual(config.clientName, "Custom MCP")
|
||||
assert.strictEqual(config.clientUri, "https://example.com/custom")
|
||||
})
|
||||
})
|
||||
})
|
||||
559
agent/extensions/pi-mcp-adapter/mcp-auth-flow.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* MCP Auth Flow
|
||||
*
|
||||
* High-level OAuth flow management using the MCP SDK's built-in auth functions.
|
||||
*/
|
||||
|
||||
import {
|
||||
auth as runSdkAuth,
|
||||
UnauthorizedError,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import open from "open"
|
||||
import { McpOAuthProvider, type McpOAuthConfig } from "./mcp-oauth-provider.ts"
|
||||
import {
|
||||
ensureCallbackServer,
|
||||
waitForCallback,
|
||||
cancelPendingCallback,
|
||||
stopCallbackServer,
|
||||
releaseCallbackServer,
|
||||
} from "./mcp-callback-server.ts"
|
||||
import {
|
||||
getAuthForUrl,
|
||||
isTokenExpired,
|
||||
hasStoredTokens,
|
||||
clearAllCredentials,
|
||||
clearClientInfo,
|
||||
clearTokens,
|
||||
clearCodeVerifier,
|
||||
updateOAuthState,
|
||||
getOAuthState,
|
||||
clearOAuthState,
|
||||
type StoredTokens,
|
||||
} from "./mcp-auth.ts"
|
||||
import type { ServerEntry } from "./types.ts"
|
||||
|
||||
/** Auth status for a server */
|
||||
export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
|
||||
|
||||
// Track pending transports for auth completion
|
||||
const pendingTransports = new Map<string, StreamableHTTPClientTransport>()
|
||||
const pendingAuthStates = new Map<string, string>()
|
||||
const pendingAuthCleanupTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
// Deduplicate concurrent authenticate() calls per server.
|
||||
const pendingAuthentications = new Map<string, Promise<AuthStatus>>()
|
||||
|
||||
/** Timeout for manual auth completion (5 minutes) */
|
||||
const MANUAL_AUTH_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure random state parameter.
|
||||
*/
|
||||
function generateState(): string {
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract OAuth configuration from a ServerEntry.
|
||||
*/
|
||||
export function extractOAuthConfig(definition: ServerEntry): McpOAuthConfig {
|
||||
if (definition.oauth === false) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const config: McpOAuthConfig = {}
|
||||
if (definition.oauth?.grantType !== undefined) config.grantType = definition.oauth.grantType
|
||||
if (definition.oauth?.clientId !== undefined) config.clientId = definition.oauth.clientId
|
||||
if (definition.oauth?.clientSecret !== undefined) config.clientSecret = definition.oauth.clientSecret
|
||||
if (definition.oauth?.scope !== undefined) config.scope = definition.oauth.scope
|
||||
if (definition.oauth?.redirectUri !== undefined) {
|
||||
if (typeof definition.oauth.redirectUri !== "string") {
|
||||
throw new Error("OAuth redirectUri must be a string")
|
||||
}
|
||||
const redirectUri = definition.oauth.redirectUri.trim()
|
||||
if (!redirectUri) {
|
||||
throw new Error("OAuth redirectUri must not be empty")
|
||||
}
|
||||
config.redirectUri = redirectUri
|
||||
}
|
||||
if (definition.oauth?.clientName !== undefined) {
|
||||
if (typeof definition.oauth.clientName !== "string") {
|
||||
throw new Error("OAuth clientName must be a string")
|
||||
}
|
||||
const clientName = definition.oauth.clientName.trim()
|
||||
if (!clientName) {
|
||||
throw new Error("OAuth clientName must not be empty")
|
||||
}
|
||||
config.clientName = clientName
|
||||
}
|
||||
if (definition.oauth?.clientUri !== undefined) {
|
||||
if (typeof definition.oauth.clientUri !== "string") {
|
||||
throw new Error("OAuth clientUri must be a string")
|
||||
}
|
||||
const clientUri = definition.oauth.clientUri.trim()
|
||||
if (!clientUri) {
|
||||
throw new Error("OAuth clientUri must not be empty")
|
||||
}
|
||||
config.clientUri = clientUri
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
function parseOAuthRedirectUri(redirectUri: string): { port: number; callbackHost: string; callbackPath: string } {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(redirectUri)
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid OAuth redirectUri: ${redirectUri}`, { cause: error })
|
||||
}
|
||||
|
||||
const hostname = url.hostname.toLowerCase()
|
||||
const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"
|
||||
if (url.protocol !== "http:" || !isLocalhost) {
|
||||
throw new Error("OAuth redirectUri must be an http:// localhost or loopback URI")
|
||||
}
|
||||
|
||||
if (url.username || url.password) {
|
||||
throw new Error("OAuth redirectUri must not include username or password")
|
||||
}
|
||||
|
||||
if (url.hash) {
|
||||
throw new Error("OAuth redirectUri must not include a fragment")
|
||||
}
|
||||
|
||||
if (!url.port) {
|
||||
throw new Error("OAuth redirectUri must include an explicit numeric port")
|
||||
}
|
||||
|
||||
const port = Number.parseInt(url.port, 10)
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
throw new Error("OAuth redirectUri must include an explicit numeric port")
|
||||
}
|
||||
|
||||
const callbackHost = hostname === "[::1]" ? "::1" : hostname
|
||||
return { port, callbackHost, callbackPath: url.pathname }
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth authentication flow for a server.
|
||||
* Returns the authorization URL when browser authorization is required.
|
||||
*/
|
||||
export async function startAuth(
|
||||
serverName: string,
|
||||
serverUrl: string,
|
||||
definition?: ServerEntry
|
||||
): Promise<{ authorizationUrl: string }> {
|
||||
const config = definition ? extractOAuthConfig(definition) : {}
|
||||
|
||||
if (config.grantType === "client_credentials") {
|
||||
const storedAuth = await getAuthForUrl(serverName, serverUrl)
|
||||
if (storedAuth?.clientInfo && !storedAuth.tokens && !config.clientId) {
|
||||
clearClientInfo(serverName)
|
||||
clearCodeVerifier(serverName)
|
||||
await clearOAuthState(serverName)
|
||||
}
|
||||
|
||||
const authProvider = new McpOAuthProvider(serverName, serverUrl, config, {
|
||||
onRedirect: async () => {
|
||||
throw new Error("Browser redirect is not used for client_credentials flow")
|
||||
},
|
||||
})
|
||||
const result = await runSdkAuth(authProvider, { serverUrl })
|
||||
if (result !== "AUTHORIZED") {
|
||||
throw new UnauthorizedError("Failed to authorize")
|
||||
}
|
||||
return { authorizationUrl: "" }
|
||||
}
|
||||
|
||||
const redirectCallback = config.redirectUri !== undefined ? parseOAuthRedirectUri(config.redirectUri) : undefined
|
||||
const oauthState = generateState()
|
||||
|
||||
try {
|
||||
await ensureCallbackServer({
|
||||
strictPort: Boolean(config.clientId) || config.redirectUri !== undefined,
|
||||
oauthState,
|
||||
reserveState: true,
|
||||
...(redirectCallback ? { port: redirectCallback.port, callbackHost: redirectCallback.callbackHost, callbackPath: redirectCallback.callbackPath } : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
await clearOAuthState(serverName)
|
||||
throw error
|
||||
}
|
||||
|
||||
let capturedUrl: URL | undefined
|
||||
const authProvider = new McpOAuthProvider(serverName, serverUrl, config, {
|
||||
onRedirect: async (url) => {
|
||||
capturedUrl = url
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const storedAuth = await getAuthForUrl(serverName, serverUrl)
|
||||
if (storedAuth?.clientInfo && !config.clientId) {
|
||||
if (!storedAuth.tokens) {
|
||||
clearClientInfo(serverName)
|
||||
clearCodeVerifier(serverName)
|
||||
await clearOAuthState(serverName)
|
||||
} else {
|
||||
const redirectUris = storedAuth.clientInfo.redirectUris
|
||||
if (!Array.isArray(redirectUris) || !redirectUris.includes(authProvider.redirectUrl ?? "")) {
|
||||
clearClientInfo(serverName)
|
||||
clearTokens(serverName)
|
||||
clearCodeVerifier(serverName)
|
||||
await clearOAuthState(serverName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await updateOAuthState(serverName, oauthState, serverUrl)
|
||||
|
||||
const result = await runSdkAuth(authProvider, { serverUrl })
|
||||
if (result === "AUTHORIZED") {
|
||||
releaseCallbackServer(oauthState)
|
||||
await clearOAuthState(serverName)
|
||||
return { authorizationUrl: "" }
|
||||
}
|
||||
if (!capturedUrl) {
|
||||
throw new UnauthorizedError("OAuth authorization URL was not provided")
|
||||
}
|
||||
const pendingTransport = new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider })
|
||||
await setPendingTransport(serverName, pendingTransport, oauthState)
|
||||
return { authorizationUrl: capturedUrl.toString() }
|
||||
} catch (error) {
|
||||
await clearPendingAuth(serverName, oauthState)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function setPendingTransport(
|
||||
serverName: string,
|
||||
transport: StreamableHTTPClientTransport,
|
||||
oauthState: string,
|
||||
): Promise<void> {
|
||||
await clearPendingAuth(serverName)
|
||||
pendingTransports.set(serverName, transport)
|
||||
pendingAuthStates.set(serverName, oauthState)
|
||||
const cleanupTimer = setTimeout(() => {
|
||||
void clearPendingAuth(serverName, oauthState)
|
||||
}, MANUAL_AUTH_TIMEOUT_MS)
|
||||
cleanupTimer.unref?.()
|
||||
pendingAuthCleanupTimers.set(serverName, cleanupTimer)
|
||||
}
|
||||
|
||||
async function clearPendingAuth(serverName: string, oauthState?: string): Promise<void> {
|
||||
const pendingState = pendingAuthStates.get(serverName)
|
||||
if (oauthState && pendingState && pendingState !== oauthState) return
|
||||
|
||||
const timer = pendingAuthCleanupTimers.get(serverName)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
pendingAuthCleanupTimers.delete(serverName)
|
||||
}
|
||||
|
||||
const transport = pendingTransports.get(serverName)
|
||||
pendingTransports.delete(serverName)
|
||||
pendingAuthStates.delete(serverName)
|
||||
const stateToRelease = pendingState ?? oauthState
|
||||
if (stateToRelease) {
|
||||
releaseCallbackServer(stateToRelease)
|
||||
const storedState = await getOAuthState(serverName)
|
||||
if (storedState === stateToRelease) {
|
||||
await clearOAuthState(serverName)
|
||||
}
|
||||
}
|
||||
if (transport) {
|
||||
await transport.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function getSearchParamsFromInput(input: string): URLSearchParams | undefined {
|
||||
try {
|
||||
const url = new URL(input)
|
||||
const params = new URLSearchParams(url.search)
|
||||
if (url.hash) {
|
||||
const hash = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash
|
||||
const hashParams = new URLSearchParams(hash)
|
||||
for (const [key, value] of hashParams) {
|
||||
if (!params.has(key)) params.set(key, value)
|
||||
}
|
||||
}
|
||||
return params
|
||||
} catch {
|
||||
const query = input.includes("?") ? input.slice(input.indexOf("?") + 1) : input
|
||||
const params = new URLSearchParams(query.startsWith("#") ? query.slice(1) : query)
|
||||
return params.has("code") || params.has("state") || params.has("error") ? params : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an OAuth authorization code from either a raw code, a query string,
|
||||
* or the full localhost redirect URL copied from the browser address bar.
|
||||
*/
|
||||
export function parseAuthorizationCodeInput(input: string, expectedState?: string): string {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
throw new Error("Authorization code or redirect URL is required")
|
||||
}
|
||||
|
||||
const params = getSearchParamsFromInput(trimmed)
|
||||
if (params) {
|
||||
const error = params.get("error")
|
||||
if (error) {
|
||||
const description = params.get("error_description")
|
||||
throw new Error(description ? `${error}: ${description}` : error)
|
||||
}
|
||||
|
||||
const state = params.get("state")
|
||||
if (expectedState && !state) {
|
||||
throw new Error("OAuth state missing from redirect URL")
|
||||
}
|
||||
if (expectedState && state !== expectedState) {
|
||||
throw new Error("OAuth state mismatch - potential CSRF attack")
|
||||
}
|
||||
|
||||
const code = params.get("code")
|
||||
if (code) return code
|
||||
}
|
||||
|
||||
if (/^[A-Za-z0-9._~+/=-]+$/.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
throw new Error("Could not find an OAuth authorization code in the provided input")
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete OAuth authentication from manual user input.
|
||||
*/
|
||||
export async function completeAuthFromInput(
|
||||
serverName: string,
|
||||
input: string,
|
||||
): Promise<AuthStatus> {
|
||||
const oauthState = await getOAuthState(serverName)
|
||||
const code = parseAuthorizationCodeInput(input, oauthState)
|
||||
return completeAuth(serverName, code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete OAuth authentication with the authorization code.
|
||||
*/
|
||||
export async function completeAuth(
|
||||
serverName: string,
|
||||
authorizationCode: string
|
||||
): Promise<AuthStatus> {
|
||||
const transport = pendingTransports.get(serverName)
|
||||
if (!transport) {
|
||||
throw new Error(`No pending OAuth flow for server: ${serverName}`)
|
||||
}
|
||||
|
||||
const oauthState = await getOAuthState(serverName)
|
||||
|
||||
try {
|
||||
// Complete the auth using the transport's finishAuth method
|
||||
await transport.finishAuth(authorizationCode)
|
||||
return "authenticated"
|
||||
} finally {
|
||||
await clearPendingAuth(serverName, oauthState)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the complete OAuth authentication flow for a server.
|
||||
*
|
||||
* @param serverName - The name of the MCP server
|
||||
* @param serverUrl - The URL of the MCP server
|
||||
* @param definition - The server definition (optional)
|
||||
* @returns The final auth status
|
||||
*/
|
||||
export async function authenticate(
|
||||
serverName: string,
|
||||
serverUrl: string,
|
||||
definition?: ServerEntry,
|
||||
): Promise<AuthStatus> {
|
||||
const inFlight = pendingAuthentications.get(serverName)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
|
||||
const operation = (async (): Promise<AuthStatus> => {
|
||||
// Start auth flow
|
||||
const { authorizationUrl } = await startAuth(serverName, serverUrl, definition)
|
||||
|
||||
// If no auth URL needed, already authenticated
|
||||
if (!authorizationUrl) {
|
||||
return "authenticated"
|
||||
}
|
||||
|
||||
// Get the state that was already generated and stored in startAuth()
|
||||
const oauthState = await getOAuthState(serverName)
|
||||
if (!oauthState) {
|
||||
throw new Error("OAuth state not found - this should not happen")
|
||||
}
|
||||
|
||||
// Register the callback BEFORE opening the browser
|
||||
const callbackPromise = waitForCallback(oauthState)
|
||||
|
||||
try {
|
||||
// Open browser. Always print the URL first so remote/headless users can copy it
|
||||
// even when the OS browser handoff is unavailable or invisible.
|
||||
console.log(`MCP Auth: Open this URL to authenticate ${serverName}:\n${authorizationUrl}`)
|
||||
try {
|
||||
await open(authorizationUrl)
|
||||
} catch (error) {
|
||||
console.warn(`MCP Auth: Failed to open browser for ${serverName}; waiting for manual callback`, { error })
|
||||
}
|
||||
|
||||
// Wait for callback
|
||||
const code = await callbackPromise
|
||||
|
||||
// Validate state
|
||||
const storedState = await getOAuthState(serverName)
|
||||
if (storedState !== oauthState) {
|
||||
await clearOAuthState(serverName)
|
||||
throw new Error("OAuth state mismatch - potential CSRF attack")
|
||||
}
|
||||
await clearOAuthState(serverName)
|
||||
|
||||
// Complete the auth
|
||||
return await completeAuth(serverName, code)
|
||||
} catch (error) {
|
||||
cancelPendingCallback(oauthState)
|
||||
await clearPendingAuth(serverName, oauthState)
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
|
||||
pendingAuthentications.set(serverName, operation)
|
||||
|
||||
try {
|
||||
return await operation
|
||||
} finally {
|
||||
if (pendingAuthentications.get(serverName) === operation) {
|
||||
pendingAuthentications.delete(serverName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid access token for a server, refreshing if necessary.
|
||||
*
|
||||
* @param serverName - The name of the MCP server
|
||||
* @param serverUrl - The URL of the MCP server
|
||||
* @returns The valid tokens or null if not authenticated
|
||||
*/
|
||||
export async function getValidToken(
|
||||
serverName: string,
|
||||
serverUrl: string,
|
||||
): Promise<StoredTokens | null> {
|
||||
// Check if we have valid tokens
|
||||
const entry = await getAuthForUrl(serverName, serverUrl)
|
||||
if (!entry?.tokens) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
const expired = await isTokenExpired(serverName)
|
||||
if (expired === false) {
|
||||
return entry.tokens
|
||||
}
|
||||
|
||||
if (expired === true && entry.tokens.refreshToken) {
|
||||
// Token is expired, try to refresh
|
||||
console.log(`MCP Auth: Token expired for ${serverName}, attempting refresh`)
|
||||
|
||||
try {
|
||||
// Create auth provider for token refresh
|
||||
const authProvider = new McpOAuthProvider(serverName, serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
|
||||
const clientInfo = await authProvider.clientInformation()
|
||||
if (!clientInfo) {
|
||||
console.log(`MCP Auth: No client info for refresh for ${serverName}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await runSdkAuth(authProvider, { serverUrl })
|
||||
if (result !== "AUTHORIZED") {
|
||||
return null
|
||||
}
|
||||
const refreshed = await getAuthForUrl(serverName, serverUrl)
|
||||
return refreshed?.tokens ?? null
|
||||
} catch (error) {
|
||||
console.error(`MCP Auth: Token refresh failed for ${serverName}`, { error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// No expiration info or no refresh token, assume valid
|
||||
return entry.tokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the authentication status for a server.
|
||||
*
|
||||
* @param serverName - The name of the MCP server
|
||||
* @returns The current auth status
|
||||
*/
|
||||
export async function getAuthStatus(serverName: string): Promise<AuthStatus> {
|
||||
const hasTokens = await hasStoredTokens(serverName)
|
||||
if (!hasTokens) return "not_authenticated"
|
||||
|
||||
const expired = await isTokenExpired(serverName)
|
||||
return expired ? "expired" : "authenticated"
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all OAuth credentials for a server.
|
||||
*
|
||||
* @param serverName - The name of the MCP server
|
||||
*/
|
||||
export async function removeAuth(serverName: string): Promise<void> {
|
||||
const oauthState = await getOAuthState(serverName)
|
||||
if (oauthState) {
|
||||
cancelPendingCallback(oauthState)
|
||||
}
|
||||
await clearPendingAuth(serverName, oauthState)
|
||||
clearAllCredentials(serverName)
|
||||
await clearOAuthState(serverName)
|
||||
console.log(`MCP Auth: Removed credentials for ${serverName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth is supported for a server configuration.
|
||||
* OAuth is supported for HTTP servers unless explicitly disabled.
|
||||
*
|
||||
* @param definition - The server definition
|
||||
* @returns True if OAuth is supported
|
||||
*/
|
||||
export function supportsOAuth(definition: ServerEntry): boolean {
|
||||
// OAuth requires a URL
|
||||
if (!definition.url) return false
|
||||
|
||||
// Explicitly disabled via auth: false or oauth: false
|
||||
if (definition.auth === false) return false
|
||||
if (definition.oauth === false) return false
|
||||
|
||||
// OAuth is enabled if auth is 'oauth' or not specified (auto-detect)
|
||||
return definition.auth === "oauth" || definition.auth === undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the OAuth system on startup.
|
||||
* OAuth callback binding is lazy and starts from startAuth() only.
|
||||
*/
|
||||
export async function initializeOAuth(): Promise<void> {}
|
||||
|
||||
/**
|
||||
* Shutdown the OAuth system.
|
||||
* Stops the callback server and cancels pending auths.
|
||||
*/
|
||||
export async function shutdownOAuth(): Promise<void> {
|
||||
for (const serverName of Array.from(pendingTransports.keys())) {
|
||||
await clearPendingAuth(serverName)
|
||||
}
|
||||
await stopCallbackServer()
|
||||
}
|
||||
373
agent/extensions/pi-mcp-adapter/mcp-auth.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Tests for mcp-auth.ts - Auth storage module
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { mkdirSync, rmSync, existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
// Set up isolated temp directory for tests
|
||||
const TEST_DIR = join(tmpdir(), `mcp-oauth-test-${randomBytes(4).toString('hex')}`)
|
||||
process.env.MCP_OAUTH_DIR = TEST_DIR
|
||||
|
||||
import {
|
||||
getAuthEntry,
|
||||
getAuthForUrl,
|
||||
saveAuthEntry,
|
||||
removeAuthEntry,
|
||||
updateTokens,
|
||||
updateClientInfo,
|
||||
updateCodeVerifier,
|
||||
clearCodeVerifier,
|
||||
updateOAuthState,
|
||||
getOAuthState,
|
||||
clearOAuthState,
|
||||
isTokenExpired,
|
||||
hasStoredTokens,
|
||||
clearAllCredentials,
|
||||
clearClientInfo,
|
||||
clearTokens,
|
||||
type AuthEntry,
|
||||
} from "./mcp-auth.ts"
|
||||
|
||||
describe("mcp-auth", () => {
|
||||
before(() => {
|
||||
// Ensure clean state
|
||||
try {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
after(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
describe("getAuthEntry", () => {
|
||||
it("should return undefined for non-existent entry", () => {
|
||||
const entry = getAuthEntry("non-existent")
|
||||
assert.strictEqual(entry, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("saveAuthEntry / getAuthEntry", () => {
|
||||
it("should save and retrieve an auth entry", () => {
|
||||
const entry: AuthEntry = {
|
||||
tokens: {
|
||||
accessToken: "test-token",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: 1234567890,
|
||||
scope: "read write",
|
||||
},
|
||||
serverUrl: "https://api.example.com",
|
||||
}
|
||||
|
||||
saveAuthEntry("test-server", entry, "https://api.example.com")
|
||||
const retrieved = getAuthEntry("test-server")
|
||||
|
||||
assert.deepStrictEqual(retrieved, entry)
|
||||
})
|
||||
|
||||
it("should update existing entries", () => {
|
||||
const entry1: AuthEntry = {
|
||||
tokens: { accessToken: "token1" },
|
||||
serverUrl: "https://api.example.com",
|
||||
}
|
||||
const entry2: AuthEntry = {
|
||||
tokens: { accessToken: "token2" },
|
||||
serverUrl: "https://api.example.com",
|
||||
}
|
||||
|
||||
saveAuthEntry("test-server", entry1, "https://api.example.com")
|
||||
saveAuthEntry("test-server", entry2, "https://api.example.com")
|
||||
const retrieved = getAuthEntry("test-server")
|
||||
|
||||
assert.strictEqual(retrieved?.tokens?.accessToken, "token2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAuthForUrl", () => {
|
||||
it("should return entry when URL matches", () => {
|
||||
const entry: AuthEntry = {
|
||||
tokens: { accessToken: "test-token" },
|
||||
serverUrl: "https://api.example.com",
|
||||
}
|
||||
|
||||
saveAuthEntry("test-server", entry, "https://api.example.com")
|
||||
const retrieved = getAuthForUrl("test-server", "https://api.example.com")
|
||||
|
||||
assert.deepStrictEqual(retrieved, entry)
|
||||
})
|
||||
|
||||
it("should return undefined when URL doesn't match", () => {
|
||||
const entry: AuthEntry = {
|
||||
tokens: { accessToken: "test-token" },
|
||||
serverUrl: "https://api.example.com",
|
||||
}
|
||||
|
||||
saveAuthEntry("test-server", entry, "https://api.example.com")
|
||||
const retrieved = getAuthForUrl("test-server", "https://different.com")
|
||||
|
||||
assert.strictEqual(retrieved, undefined)
|
||||
})
|
||||
|
||||
it("should return undefined when serverUrl is not stored", () => {
|
||||
const entry: AuthEntry = {
|
||||
tokens: { accessToken: "test-token" },
|
||||
}
|
||||
|
||||
saveAuthEntry("test-server", entry)
|
||||
const retrieved = getAuthForUrl("test-server", "https://api.example.com")
|
||||
|
||||
assert.strictEqual(retrieved, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeAuthEntry", () => {
|
||||
it("should remove an entry", () => {
|
||||
const entry: AuthEntry = {
|
||||
tokens: { accessToken: "test-token" },
|
||||
}
|
||||
|
||||
saveAuthEntry("test-server", entry)
|
||||
removeAuthEntry("test-server")
|
||||
const retrieved = getAuthEntry("test-server")
|
||||
|
||||
assert.strictEqual(retrieved, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateTokens", () => {
|
||||
it("should update tokens for a server", () => {
|
||||
updateTokens("test-server", {
|
||||
accessToken: "new-token",
|
||||
refreshToken: "new-refresh",
|
||||
expiresAt: 1234567890,
|
||||
scope: "read",
|
||||
})
|
||||
|
||||
const entry = getAuthEntry("test-server")
|
||||
assert.strictEqual(entry?.tokens?.accessToken, "new-token")
|
||||
})
|
||||
|
||||
it("should preserve existing client info", () => {
|
||||
updateClientInfo("test-server", { clientId: "client-123" })
|
||||
updateTokens("test-server", { accessToken: "token" })
|
||||
|
||||
const entry = getAuthEntry("test-server")
|
||||
assert.strictEqual(entry?.clientInfo?.clientId, "client-123")
|
||||
assert.strictEqual(entry?.tokens?.accessToken, "token")
|
||||
})
|
||||
|
||||
it("should clear URL-bound auth state when tokens move to a different server URL", () => {
|
||||
saveAuthEntry("token-url-change", {
|
||||
tokens: { accessToken: "old-token", refreshToken: "old-refresh" },
|
||||
clientInfo: { clientId: "old-client" },
|
||||
codeVerifier: "old-verifier",
|
||||
oauthState: "old-state",
|
||||
serverUrl: "https://old.example.com/mcp",
|
||||
}, "https://old.example.com/mcp")
|
||||
|
||||
updateTokens("token-url-change", { accessToken: "new-token" }, "https://new.example.com/mcp")
|
||||
|
||||
assert.strictEqual(getAuthForUrl("token-url-change", "https://old.example.com/mcp"), undefined)
|
||||
const newEntry = getAuthForUrl("token-url-change", "https://new.example.com/mcp")
|
||||
assert.strictEqual(newEntry?.tokens?.accessToken, "new-token")
|
||||
assert.strictEqual(newEntry?.clientInfo, undefined)
|
||||
assert.strictEqual(newEntry?.codeVerifier, undefined)
|
||||
assert.strictEqual(newEntry?.oauthState, undefined)
|
||||
})
|
||||
|
||||
it("should clear legacy URL-bound auth state when saving tokens with a server URL", () => {
|
||||
saveAuthEntry("token-legacy-url-change", {
|
||||
tokens: { accessToken: "old-token", refreshToken: "old-refresh" },
|
||||
clientInfo: { clientId: "old-client" },
|
||||
codeVerifier: "old-verifier",
|
||||
oauthState: "old-state",
|
||||
})
|
||||
|
||||
updateTokens("token-legacy-url-change", { accessToken: "new-token" }, "https://new.example.com/mcp")
|
||||
|
||||
const newEntry = getAuthForUrl("token-legacy-url-change", "https://new.example.com/mcp")
|
||||
assert.strictEqual(newEntry?.tokens?.accessToken, "new-token")
|
||||
assert.strictEqual(newEntry?.clientInfo, undefined)
|
||||
assert.strictEqual(newEntry?.codeVerifier, undefined)
|
||||
assert.strictEqual(newEntry?.oauthState, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateClientInfo", () => {
|
||||
it("should update client info for a server", () => {
|
||||
updateClientInfo("test-server", {
|
||||
clientId: "client-123",
|
||||
clientSecret: "secret",
|
||||
clientIdIssuedAt: 1234567890,
|
||||
clientSecretExpiresAt: 1234567999,
|
||||
})
|
||||
|
||||
const entry = getAuthEntry("test-server")
|
||||
assert.strictEqual(entry?.clientInfo?.clientId, "client-123")
|
||||
assert.strictEqual(entry?.clientInfo?.clientSecret, "secret")
|
||||
})
|
||||
|
||||
it("should clear URL-bound credentials when client info moves to a different server URL", () => {
|
||||
saveAuthEntry("url-change", {
|
||||
tokens: { accessToken: "old-token", refreshToken: "old-refresh" },
|
||||
clientInfo: { clientId: "old-client" },
|
||||
codeVerifier: "old-verifier",
|
||||
oauthState: "old-state",
|
||||
serverUrl: "https://old.example.com/mcp",
|
||||
}, "https://old.example.com/mcp")
|
||||
|
||||
updateClientInfo("url-change", { clientId: "new-client" }, "https://new.example.com/mcp")
|
||||
|
||||
assert.strictEqual(getAuthForUrl("url-change", "https://old.example.com/mcp"), undefined)
|
||||
const newEntry = getAuthForUrl("url-change", "https://new.example.com/mcp")
|
||||
assert.strictEqual(newEntry?.clientInfo?.clientId, "new-client")
|
||||
assert.strictEqual(newEntry?.tokens, undefined)
|
||||
assert.strictEqual(newEntry?.codeVerifier, undefined)
|
||||
assert.strictEqual(newEntry?.oauthState, undefined)
|
||||
})
|
||||
|
||||
it("should clear stale verifier and state when legacy client info gains a server URL", () => {
|
||||
saveAuthEntry("legacy-url-change", {
|
||||
tokens: { accessToken: "old-token", refreshToken: "old-refresh" },
|
||||
clientInfo: { clientId: "old-client" },
|
||||
codeVerifier: "old-verifier",
|
||||
oauthState: "old-state",
|
||||
})
|
||||
|
||||
updateClientInfo("legacy-url-change", { clientId: "new-client" }, "https://new.example.com/mcp")
|
||||
|
||||
const newEntry = getAuthForUrl("legacy-url-change", "https://new.example.com/mcp")
|
||||
assert.strictEqual(newEntry?.clientInfo?.clientId, "new-client")
|
||||
assert.strictEqual(newEntry?.tokens, undefined)
|
||||
assert.strictEqual(newEntry?.codeVerifier, undefined)
|
||||
assert.strictEqual(newEntry?.oauthState, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateCodeVerifier / clearCodeVerifier", () => {
|
||||
it("should save and retrieve code verifier", () => {
|
||||
updateCodeVerifier("test-server", "verifier-123")
|
||||
const entry = getAuthEntry("test-server")
|
||||
assert.strictEqual(entry?.codeVerifier, "verifier-123")
|
||||
})
|
||||
|
||||
it("should clear code verifier", () => {
|
||||
updateCodeVerifier("test-server", "verifier-123")
|
||||
clearCodeVerifier("test-server")
|
||||
const entry = getAuthEntry("test-server")
|
||||
assert.strictEqual(entry?.codeVerifier, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateOAuthState / getOAuthState / clearOAuthState", () => {
|
||||
it("should save and retrieve OAuth state", () => {
|
||||
updateOAuthState("test-server", "state-abc-123")
|
||||
const state = getOAuthState("test-server")
|
||||
assert.strictEqual(state, "state-abc-123")
|
||||
})
|
||||
|
||||
it("should clear OAuth state", () => {
|
||||
updateOAuthState("test-server", "state-abc-123")
|
||||
clearOAuthState("test-server")
|
||||
const state = getOAuthState("test-server")
|
||||
assert.strictEqual(state, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isTokenExpired", () => {
|
||||
it("should return null if no tokens", () => {
|
||||
const expired = isTokenExpired("expiry-test-null")
|
||||
assert.strictEqual(expired, null)
|
||||
})
|
||||
|
||||
it("should return false if no expiry", () => {
|
||||
updateTokens("expiry-test-no-expiry", { accessToken: "token" })
|
||||
const expired = isTokenExpired("expiry-test-no-expiry")
|
||||
assert.strictEqual(expired, false)
|
||||
})
|
||||
|
||||
it("should return true if expired", () => {
|
||||
updateTokens("expiry-test-expired", {
|
||||
accessToken: "token",
|
||||
expiresAt: 1, // Way in the past
|
||||
})
|
||||
const expired = isTokenExpired("expiry-test-expired")
|
||||
assert.strictEqual(expired, true)
|
||||
})
|
||||
|
||||
it("should return false if not expired", () => {
|
||||
updateTokens("expiry-test-future", {
|
||||
accessToken: "token",
|
||||
expiresAt: Date.now() / 1000 + 3600, // 1 hour from now
|
||||
})
|
||||
const expired = isTokenExpired("expiry-test-future")
|
||||
assert.strictEqual(expired, false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasStoredTokens", () => {
|
||||
it("should return false if no tokens", () => {
|
||||
assert.strictEqual(hasStoredTokens("has-tokens-test-false"), false)
|
||||
})
|
||||
|
||||
it("should return true if tokens exist", () => {
|
||||
updateTokens("has-tokens-test-true", { accessToken: "token" })
|
||||
assert.strictEqual(hasStoredTokens("has-tokens-test-true"), true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearAllCredentials", () => {
|
||||
it("should remove all credentials", () => {
|
||||
updateTokens("test-server", { accessToken: "token" })
|
||||
updateClientInfo("test-server", { clientId: "client" })
|
||||
updateCodeVerifier("test-server", "verifier")
|
||||
|
||||
clearAllCredentials("test-server")
|
||||
|
||||
assert.strictEqual(getAuthEntry("test-server"), undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearClientInfo", () => {
|
||||
it("should only remove client info", () => {
|
||||
updateTokens("test-server", { accessToken: "token" })
|
||||
updateClientInfo("test-server", { clientId: "client" })
|
||||
|
||||
clearClientInfo("test-server")
|
||||
|
||||
const entry = getAuthEntry("test-server")
|
||||
assert.strictEqual(entry?.clientInfo, undefined)
|
||||
assert.strictEqual(entry?.tokens?.accessToken, "token")
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearTokens", () => {
|
||||
it("should only remove tokens", () => {
|
||||
updateTokens("test-server", { accessToken: "token" })
|
||||
updateClientInfo("test-server", { clientId: "client" })
|
||||
|
||||
clearTokens("test-server")
|
||||
|
||||
const entry = getAuthEntry("test-server")
|
||||
assert.strictEqual(entry?.tokens, undefined)
|
||||
assert.strictEqual(entry?.clientInfo?.clientId, "client")
|
||||
})
|
||||
})
|
||||
})
|
||||
302
agent/extensions/pi-mcp-adapter/mcp-auth.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* MCP Auth Storage Module
|
||||
*
|
||||
* Handles secure storage of OAuth credentials, tokens, client information,
|
||||
* and PKCE state for MCP servers.
|
||||
*
|
||||
* Token storage location: $MCP_OAUTH_DIR/sha256-<server-hash>/tokens.json when set,
|
||||
* otherwise <Pi agent dir>/mcp-oauth/sha256-<server-hash>/tokens.json
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { getAgentPath } from './agent-dir.ts';
|
||||
|
||||
/** OAuth token storage format */
|
||||
export interface StoredTokens {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number; // Unix timestamp in seconds
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/** OAuth client information from dynamic or static registration */
|
||||
export interface StoredClientInfo {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
clientIdIssuedAt?: number;
|
||||
clientSecretExpiresAt?: number;
|
||||
redirectUris?: string[];
|
||||
}
|
||||
|
||||
/** Complete auth entry for a server */
|
||||
export interface AuthEntry {
|
||||
tokens?: StoredTokens;
|
||||
clientInfo?: StoredClientInfo;
|
||||
codeVerifier?: string;
|
||||
oauthState?: string;
|
||||
serverUrl?: string; // Track the URL these credentials are for
|
||||
}
|
||||
|
||||
// Base directory for auth storage - can be overridden via env var for testing
|
||||
function getAuthBaseDir(): string {
|
||||
const override = process.env.MCP_OAUTH_DIR?.trim();
|
||||
return override ? override : getAgentPath('mcp-oauth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server-specific directory path.
|
||||
*/
|
||||
function getServerDir(serverName: string): string {
|
||||
if (typeof serverName !== 'string') {
|
||||
throw new Error(`Invalid MCP server name: ${JSON.stringify(serverName)}`);
|
||||
}
|
||||
const storageKey = createHash('sha256').update(serverName, 'utf8').digest('hex');
|
||||
return join(getAuthBaseDir(), `sha256-${storageKey}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tokens file path for a server.
|
||||
*/
|
||||
export function getAuthEntryFilePath(serverName: string): string {
|
||||
return join(getServerDir(serverName), 'tokens.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the server directory exists with secure permissions.
|
||||
*/
|
||||
function ensureServerDir(serverName: string): void {
|
||||
const dir = getServerDir(serverName);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the auth entry for a server from disk.
|
||||
* Returns undefined if file doesn't exist.
|
||||
*/
|
||||
function readAuthEntry(serverName: string): AuthEntry | undefined {
|
||||
const filePath = getAuthEntryFilePath(serverName);
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return undefined;
|
||||
}
|
||||
const data = readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(data) as AuthEntry;
|
||||
} catch (error) {
|
||||
console.error(`Failed to read auth entry for ${serverName}:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the auth entry for a server to disk with secure permissions.
|
||||
*/
|
||||
function writeAuthEntry(serverName: string, entry: AuthEntry): void {
|
||||
ensureServerDir(serverName);
|
||||
const filePath = getAuthEntryFilePath(serverName);
|
||||
writeFileSync(filePath, JSON.stringify(entry, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth entry for a server.
|
||||
*/
|
||||
export function getAuthEntry(serverName: string): AuthEntry | undefined {
|
||||
return readAuthEntry(serverName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth entry and validate it's for the correct URL.
|
||||
* Returns undefined if URL has changed (credentials are invalid).
|
||||
*/
|
||||
export function getAuthForUrl(serverName: string, serverUrl: string): AuthEntry | undefined {
|
||||
const entry = getAuthEntry(serverName);
|
||||
if (!entry) return undefined;
|
||||
|
||||
// If no serverUrl is stored, this is from an old version - consider it invalid
|
||||
if (!entry.serverUrl) return undefined;
|
||||
|
||||
// If URL has changed, credentials are invalid
|
||||
if (entry.serverUrl !== serverUrl) return undefined;
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save auth entry for a server.
|
||||
*/
|
||||
export function saveAuthEntry(serverName: string, entry: AuthEntry, serverUrl?: string): void {
|
||||
// Always update serverUrl if provided
|
||||
if (serverUrl) {
|
||||
entry.serverUrl = serverUrl;
|
||||
}
|
||||
writeAuthEntry(serverName, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove auth entry for a server.
|
||||
* Also removes the server directory if empty.
|
||||
*/
|
||||
export function removeAuthEntry(serverName: string): void {
|
||||
try {
|
||||
const filePath = getAuthEntryFilePath(serverName);
|
||||
if (existsSync(filePath)) {
|
||||
writeFileSync(filePath, '{}', { mode: 0o600 });
|
||||
}
|
||||
// Try to remove the directory
|
||||
const dir = getServerDir(serverName);
|
||||
if (existsSync(dir)) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true });
|
||||
} catch {
|
||||
// Directory may not be empty, ignore
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to remove auth entry for ${serverName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tokens for a server.
|
||||
*/
|
||||
export function updateTokens(
|
||||
serverName: string,
|
||||
tokens: StoredTokens,
|
||||
serverUrl?: string
|
||||
): void {
|
||||
const entry = getAuthEntry(serverName) ?? {};
|
||||
if (serverUrl && entry.serverUrl !== serverUrl) {
|
||||
delete entry.clientInfo;
|
||||
delete entry.codeVerifier;
|
||||
delete entry.oauthState;
|
||||
}
|
||||
entry.tokens = tokens;
|
||||
saveAuthEntry(serverName, entry, serverUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update client info for a server.
|
||||
*/
|
||||
export function updateClientInfo(
|
||||
serverName: string,
|
||||
clientInfo: StoredClientInfo,
|
||||
serverUrl?: string
|
||||
): void {
|
||||
const entry = getAuthEntry(serverName) ?? {};
|
||||
if (serverUrl && entry.serverUrl !== serverUrl) {
|
||||
delete entry.tokens;
|
||||
delete entry.codeVerifier;
|
||||
delete entry.oauthState;
|
||||
}
|
||||
entry.clientInfo = clientInfo;
|
||||
saveAuthEntry(serverName, entry, serverUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update code verifier for a server.
|
||||
*/
|
||||
export function updateCodeVerifier(serverName: string, codeVerifier: string, serverUrl?: string): void {
|
||||
const entry = getAuthEntry(serverName) ?? {};
|
||||
if (serverUrl && entry.serverUrl !== serverUrl) {
|
||||
delete entry.tokens;
|
||||
delete entry.clientInfo;
|
||||
delete entry.oauthState;
|
||||
}
|
||||
entry.codeVerifier = codeVerifier;
|
||||
saveAuthEntry(serverName, entry, serverUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear code verifier for a server.
|
||||
*/
|
||||
export function clearCodeVerifier(serverName: string): void {
|
||||
const entry = getAuthEntry(serverName);
|
||||
if (entry) {
|
||||
delete entry.codeVerifier;
|
||||
saveAuthEntry(serverName, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update OAuth state for a server.
|
||||
*/
|
||||
export function updateOAuthState(serverName: string, state: string, serverUrl?: string): void {
|
||||
const entry = getAuthEntry(serverName) ?? {};
|
||||
if (serverUrl && entry.serverUrl !== serverUrl) {
|
||||
delete entry.tokens;
|
||||
delete entry.clientInfo;
|
||||
delete entry.codeVerifier;
|
||||
}
|
||||
entry.oauthState = state;
|
||||
saveAuthEntry(serverName, entry, serverUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth state for a server.
|
||||
*/
|
||||
export function getOAuthState(serverName: string): string | undefined {
|
||||
const entry = getAuthEntry(serverName);
|
||||
return entry?.oauthState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear OAuth state for a server.
|
||||
*/
|
||||
export function clearOAuthState(serverName: string): void {
|
||||
const entry = getAuthEntry(serverName);
|
||||
if (entry) {
|
||||
delete entry.oauthState;
|
||||
saveAuthEntry(serverName, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if stored tokens are expired.
|
||||
* Returns null if no tokens exist, false if no expiry or not expired, true if expired.
|
||||
*/
|
||||
export function isTokenExpired(serverName: string): boolean | null {
|
||||
const entry = getAuthEntry(serverName);
|
||||
if (!entry?.tokens) return null;
|
||||
if (!entry.tokens.expiresAt) return false;
|
||||
return entry.tokens.expiresAt < Date.now() / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a server has stored tokens.
|
||||
*/
|
||||
export function hasStoredTokens(serverName: string): boolean {
|
||||
const entry = getAuthEntry(serverName);
|
||||
return !!entry?.tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all credentials for a server.
|
||||
*/
|
||||
export function clearAllCredentials(serverName: string): void {
|
||||
removeAuthEntry(serverName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only client info for a server.
|
||||
*/
|
||||
export function clearClientInfo(serverName: string): void {
|
||||
const entry = getAuthEntry(serverName);
|
||||
if (entry) {
|
||||
delete entry.clientInfo;
|
||||
saveAuthEntry(serverName, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only tokens for a server.
|
||||
*/
|
||||
export function clearTokens(serverName: string): void {
|
||||
const entry = getAuthEntry(serverName);
|
||||
if (entry) {
|
||||
delete entry.tokens;
|
||||
saveAuthEntry(serverName, entry);
|
||||
}
|
||||
}
|
||||
416
agent/extensions/pi-mcp-adapter/mcp-callback-server.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* Tests for mcp-callback-server.ts - OAuth callback server
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { createServer } from "node:http"
|
||||
import {
|
||||
ensureCallbackServer,
|
||||
waitForCallback,
|
||||
cancelPendingCallback,
|
||||
stopCallbackServer,
|
||||
isCallbackServerRunning,
|
||||
getPendingAuthCount,
|
||||
releaseCallbackServer,
|
||||
} from "./mcp-callback-server.ts"
|
||||
import { getConfiguredOAuthCallbackPort, getOAuthCallbackPath, getOAuthCallbackPort } from "./mcp-oauth-provider.ts"
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const probe = createServer()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
probe.once("error", reject)
|
||||
probe.listen(0, "localhost", resolve)
|
||||
})
|
||||
const address = probe.address()
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()))
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to reserve a free test port")
|
||||
}
|
||||
return address.port
|
||||
}
|
||||
|
||||
describe("mcp-callback-server", () => {
|
||||
beforeEach(async () => {
|
||||
// Stop any running server before each test
|
||||
await stopCallbackServer().catch(() => {})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Stop server after each test
|
||||
await stopCallbackServer().catch(() => {})
|
||||
})
|
||||
|
||||
describe("ensureCallbackServer", () => {
|
||||
it("should start the callback server", async () => {
|
||||
await ensureCallbackServer()
|
||||
assert.strictEqual(isCallbackServerRunning(), true)
|
||||
})
|
||||
|
||||
it("should be idempotent", async () => {
|
||||
await ensureCallbackServer()
|
||||
await ensureCallbackServer()
|
||||
await ensureCallbackServer()
|
||||
assert.strictEqual(isCallbackServerRunning(), true)
|
||||
})
|
||||
|
||||
it("should reserve callback state atomically with the initial bind", async () => {
|
||||
await ensureCallbackServer({ oauthState: "reserved-initial-state", reserveState: true })
|
||||
|
||||
await assert.rejects(
|
||||
async () => await ensureCallbackServer({ callbackHost: "127.0.0.1" }),
|
||||
/cannot be switched while authorizations are pending/
|
||||
)
|
||||
|
||||
releaseCallbackServer("reserved-initial-state")
|
||||
})
|
||||
|
||||
it("should not switch callback hosts while callback state is reserved", async () => {
|
||||
await ensureCallbackServer({ oauthState: "reserved-host-state", reserveState: true })
|
||||
|
||||
await assert.rejects(
|
||||
async () => await ensureCallbackServer({ callbackHost: "127.0.0.1" }),
|
||||
/cannot be switched while authorizations are pending/
|
||||
)
|
||||
|
||||
releaseCallbackServer("reserved-host-state")
|
||||
})
|
||||
|
||||
it("should not switch callback paths while callback state is reserved", async () => {
|
||||
await ensureCallbackServer({ callbackPath: "/first/callback", oauthState: "reserved-path-state", reserveState: true })
|
||||
|
||||
await assert.rejects(
|
||||
async () => await ensureCallbackServer({ callbackPath: "/second/callback" }),
|
||||
/cannot be switched while authorizations are pending/
|
||||
)
|
||||
assert.strictEqual(getOAuthCallbackPath(), "/first/callback")
|
||||
|
||||
releaseCallbackServer("reserved-path-state")
|
||||
})
|
||||
|
||||
it("should release reserved callback state when strict binding fails", async () => {
|
||||
const port = await getFreePort()
|
||||
const blocker = createServer((_req, res) => {
|
||||
res.writeHead(200)
|
||||
res.end("blocked")
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.once("error", reject)
|
||||
blocker.listen(port, "localhost", resolve)
|
||||
})
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
async () => await ensureCallbackServer({ strictPort: true, port, oauthState: "failed-bind-state", reserveState: true }),
|
||||
/already in use/
|
||||
)
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()))
|
||||
}
|
||||
|
||||
await ensureCallbackServer({ callbackPath: "/after-failure" })
|
||||
await ensureCallbackServer({ callbackPath: "/after-failure-switch" })
|
||||
assert.strictEqual(getOAuthCallbackPath(), "/after-failure-switch")
|
||||
})
|
||||
|
||||
it("should bind an explicit strict host, port, and custom callback path", async () => {
|
||||
const port = await getFreePort()
|
||||
|
||||
await ensureCallbackServer({ strictPort: true, port, callbackHost: "127.0.0.1", callbackPath: "/custom/callback" })
|
||||
|
||||
assert.strictEqual(getOAuthCallbackPort(), port)
|
||||
assert.strictEqual(getOAuthCallbackPath(), "/custom/callback")
|
||||
assert.strictEqual((await fetch(`http://127.0.0.1:${port}/callback?code=nope&state=custom-state`)).status, 404)
|
||||
|
||||
const callbackPromise = waitForCallback("custom-state")
|
||||
const response = await fetch(`http://127.0.0.1:${port}/custom/callback?code=ok&state=custom-state`)
|
||||
assert.strictEqual(response.status, 200)
|
||||
assert.strictEqual(await callbackPromise, "ok")
|
||||
})
|
||||
|
||||
it("should reject an occupied explicit strict port", async () => {
|
||||
const port = await getFreePort()
|
||||
const blocker = createServer((_req, res) => {
|
||||
res.writeHead(200)
|
||||
res.end("blocked")
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.once("error", reject)
|
||||
blocker.listen(port, "localhost", resolve)
|
||||
})
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
async () => await ensureCallbackServer({ strictPort: true, port }),
|
||||
/already in use/
|
||||
)
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
|
||||
it("should use an OS-assigned port when the configured non-strict port is occupied", async () => {
|
||||
const configuredPort = getConfiguredOAuthCallbackPort()
|
||||
const blocker = createServer((_req, res) => {
|
||||
res.writeHead(200)
|
||||
res.end("blocked")
|
||||
})
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.once("error", reject)
|
||||
blocker.listen(configuredPort, "localhost", resolve)
|
||||
})
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "EADDRINUSE") return
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCallbackServer()
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
assert.notStrictEqual(callbackPort, configuredPort)
|
||||
|
||||
const state = "occupied-port-state"
|
||||
const callbackPromise = waitForCallback(state)
|
||||
const response = await fetch(`http://localhost:${callbackPort}/callback?code=ok&state=${state}`)
|
||||
assert.strictEqual(response.status, 200)
|
||||
assert.strictEqual(await callbackPromise, "ok")
|
||||
|
||||
await assert.rejects(
|
||||
async () => await ensureCallbackServer({ strictPort: true }),
|
||||
/already in use/
|
||||
)
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("waitForCallback / callback handling", () => {
|
||||
it("should resolve with code on successful callback", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const state = "test-state-123"
|
||||
const expectedCode = "auth-code-abc"
|
||||
|
||||
// Start waiting for callback
|
||||
const callbackPromise = waitForCallback(state)
|
||||
|
||||
// Simulate callback by making HTTP request
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/callback?code=${expectedCode}&state=${state}`
|
||||
)
|
||||
|
||||
// Should get HTML success response
|
||||
assert.strictEqual(response.status, 200)
|
||||
const html = await response.text()
|
||||
assert.ok(html.includes("Authorization Successful"))
|
||||
|
||||
// Callback promise should resolve
|
||||
const code = await callbackPromise
|
||||
assert.strictEqual(code, expectedCode)
|
||||
})
|
||||
|
||||
it("should reject on error parameter", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const state = "test-state-error"
|
||||
const errorMsg = "access_denied"
|
||||
|
||||
const callbackPromise = waitForCallback(state)
|
||||
|
||||
// Simulate error callback
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/callback?error=${errorMsg}&state=${state}`
|
||||
)
|
||||
|
||||
assert.strictEqual(response.status, 200)
|
||||
const html = await response.text()
|
||||
assert.ok(html.includes("Authorization Failed"))
|
||||
|
||||
// Callback promise should reject
|
||||
await assert.rejects(callbackPromise, /access_denied/)
|
||||
})
|
||||
|
||||
it("should escape provider-controlled OAuth error details", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const state = "test-state-error-escaping"
|
||||
const callbackPromise = waitForCallback(state)
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const description = `<script>alert("x")</script>&reason=bad`
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/callback?error=access_denied&error_description=${encodeURIComponent(description)}&state=${state}`
|
||||
)
|
||||
|
||||
assert.strictEqual(response.status, 200)
|
||||
const html = await response.text()
|
||||
assert.ok(!html.includes("<script>"))
|
||||
assert.ok(html.includes("<script>alert("x")</script>&reason=bad"))
|
||||
await assert.rejects(callbackPromise, /<script>alert\("x"\)<\/script>&reason=bad/)
|
||||
})
|
||||
|
||||
it("should not reflect OAuth error details for invalid state", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/callback?error=access_denied&error_description=${encodeURIComponent("<script>bad()</script>")}&state=invalid-state`
|
||||
)
|
||||
|
||||
assert.strictEqual(response.status, 400)
|
||||
const html = await response.text()
|
||||
assert.ok(html.includes("Invalid or expired state parameter"))
|
||||
assert.ok(!html.includes("<script>"))
|
||||
assert.ok(!html.includes("bad()"))
|
||||
})
|
||||
|
||||
it("should return 400 for missing state", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/callback?code=abc123`
|
||||
)
|
||||
|
||||
assert.strictEqual(response.status, 400)
|
||||
const html = await response.text()
|
||||
assert.ok(html.includes("Missing required state parameter"))
|
||||
})
|
||||
|
||||
it("should return 400 for invalid state", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
// Register a different state
|
||||
const pendingCallback = waitForCallback("valid-state")
|
||||
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/callback?code=abc123&state=invalid-state`
|
||||
)
|
||||
|
||||
assert.strictEqual(response.status, 400)
|
||||
const html = await response.text()
|
||||
assert.ok(html.includes("Invalid or expired state parameter"))
|
||||
|
||||
cancelPendingCallback("valid-state")
|
||||
await assert.rejects(pendingCallback, /Authorization cancelled/)
|
||||
})
|
||||
|
||||
it("should return 400 for missing code", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const state = "test-state-no-code"
|
||||
const pendingCallback = waitForCallback(state)
|
||||
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/callback?state=${state}`
|
||||
)
|
||||
|
||||
assert.strictEqual(response.status, 400)
|
||||
const html = await response.text()
|
||||
assert.ok(html.includes("No authorization code provided"))
|
||||
|
||||
cancelPendingCallback(state)
|
||||
await assert.rejects(pendingCallback, /Authorization cancelled/)
|
||||
})
|
||||
|
||||
it("should not switch callback paths while callbacks are pending", async () => {
|
||||
await ensureCallbackServer({ callbackPath: "/first/callback" })
|
||||
|
||||
const state = "pending-path-state"
|
||||
const callbackPromise = waitForCallback(state)
|
||||
|
||||
await assert.rejects(
|
||||
async () => await ensureCallbackServer({ callbackPath: "/second/callback" }),
|
||||
/cannot be switched while authorizations are pending/
|
||||
)
|
||||
assert.strictEqual(getOAuthCallbackPath(), "/first/callback")
|
||||
|
||||
cancelPendingCallback(state)
|
||||
await assert.rejects(callbackPromise, /Authorization cancelled/)
|
||||
})
|
||||
|
||||
it("should return 404 for wrong path", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const callbackPort = getOAuthCallbackPort()
|
||||
const response = await fetch(
|
||||
`http://localhost:${callbackPort}/wrong/path`
|
||||
)
|
||||
|
||||
assert.strictEqual(response.status, 404)
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancelPendingCallback", () => {
|
||||
it("should reject pending callback", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const state = "test-state-cancel"
|
||||
const callbackPromise = waitForCallback(state)
|
||||
|
||||
cancelPendingCallback(state)
|
||||
|
||||
await assert.rejects(callbackPromise, /Authorization cancelled/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("stopCallbackServer", () => {
|
||||
it("should stop the server", async () => {
|
||||
await ensureCallbackServer()
|
||||
assert.strictEqual(isCallbackServerRunning(), true)
|
||||
|
||||
await stopCallbackServer()
|
||||
assert.strictEqual(isCallbackServerRunning(), false)
|
||||
})
|
||||
|
||||
it("should reject all pending callbacks", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const state1 = "state-1"
|
||||
const state2 = "state-2"
|
||||
|
||||
const promise1 = waitForCallback(state1)
|
||||
const promise2 = waitForCallback(state2)
|
||||
|
||||
await stopCallbackServer()
|
||||
|
||||
await assert.rejects(promise1, /OAuth callback server stopped/)
|
||||
await assert.rejects(promise2, /OAuth callback server stopped/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPendingAuthCount", () => {
|
||||
it("should return 0 when no pending auths", async () => {
|
||||
await ensureCallbackServer()
|
||||
assert.strictEqual(getPendingAuthCount(), 0)
|
||||
})
|
||||
|
||||
it("should return count of pending auths", async () => {
|
||||
await ensureCallbackServer()
|
||||
|
||||
const promise1 = waitForCallback("state-1")
|
||||
assert.strictEqual(getPendingAuthCount(), 1)
|
||||
|
||||
const promise2 = waitForCallback("state-2")
|
||||
assert.strictEqual(getPendingAuthCount(), 2)
|
||||
|
||||
const promise3 = waitForCallback("state-3")
|
||||
assert.strictEqual(getPendingAuthCount(), 3)
|
||||
|
||||
cancelPendingCallback("state-1")
|
||||
cancelPendingCallback("state-2")
|
||||
cancelPendingCallback("state-3")
|
||||
await assert.rejects(promise1, /Authorization cancelled/)
|
||||
await assert.rejects(promise2, /Authorization cancelled/)
|
||||
await assert.rejects(promise3, /Authorization cancelled/)
|
||||
})
|
||||
})
|
||||
})
|
||||
372
agent/extensions/pi-mcp-adapter/mcp-callback-server.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* MCP OAuth Callback Server
|
||||
*
|
||||
* HTTP server that handles OAuth callbacks from the authorization server.
|
||||
* Uses Node.js http module for compatibility.
|
||||
*/
|
||||
|
||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http"
|
||||
import {
|
||||
DEFAULT_OAUTH_CALLBACK_PATH,
|
||||
getConfiguredOAuthCallbackPort,
|
||||
getOAuthCallbackPath,
|
||||
getOAuthCallbackPort,
|
||||
setOAuthCallbackPath,
|
||||
setOAuthCallbackPort,
|
||||
} from "./mcp-oauth-provider.ts"
|
||||
|
||||
// HTML templates for callback responses
|
||||
const HTML_SUCCESS = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Pi - Authorization Successful</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||
.container { text-align: center; padding: 2rem; }
|
||||
h1 { color: #4ade80; margin-bottom: 1rem; }
|
||||
p { color: #aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Successful</h1>
|
||||
<p>You can close this window and return to Pi.</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Pi - Authorization Failed</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||
.container { text-align: center; padding: 2rem; }
|
||||
h1 { color: #f87171; margin-bottom: 1rem; }
|
||||
p { color: #aaa; }
|
||||
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>An error occurred during authorization.</p>
|
||||
<div class="error">${escapeHtml(error)}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
/** Pending authorization request */
|
||||
interface PendingAuth {
|
||||
resolve: (code: string) => void
|
||||
reject: (error: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
/** Server singleton state */
|
||||
let server: Server | undefined
|
||||
let bindingPromise: Promise<void> | undefined
|
||||
const pendingAuths = new Map<string, PendingAuth>()
|
||||
const reservedAuthStates = new Set<string>()
|
||||
|
||||
/** Timeout for callback completion (5 minutes) */
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
interface EnsureCallbackServerOptions {
|
||||
strictPort?: boolean
|
||||
port?: number
|
||||
callbackHost?: string
|
||||
callbackPath?: string
|
||||
oauthState?: string
|
||||
reserveState?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_OAUTH_CALLBACK_HOST = "localhost"
|
||||
let callbackServerHost = DEFAULT_OAUTH_CALLBACK_HOST
|
||||
|
||||
/**
|
||||
* Handle incoming HTTP requests to the callback server.
|
||||
*/
|
||||
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
const url = new URL(req.url || "/", `http://${req.headers.host}`)
|
||||
|
||||
// Only handle the callback path
|
||||
if (url.pathname !== getOAuthCallbackPath()) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain" })
|
||||
res.end("Not found")
|
||||
return
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
// Enforce state parameter presence for CSRF protection
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingAuths.get(state)
|
||||
const isReserved = reservedAuthStates.has(state)
|
||||
|
||||
// Handle OAuth errors only for a state that belongs to an active flow.
|
||||
if (error) {
|
||||
if (!pending && !isReserved) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
const errorMsg = errorDescription || error
|
||||
// Send HTTP response first before rejecting promise
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
reservedAuthStates.delete(state)
|
||||
// Reject promise after response is sent (defer to allow test to attach handler)
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
setTimeout(() => pending.reject(new Error(errorMsg)), 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!pending) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
return
|
||||
}
|
||||
|
||||
// Require authorization code
|
||||
if (!code) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR("No authorization code provided"))
|
||||
return
|
||||
}
|
||||
|
||||
// Clear timeout and resolve the pending promise
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
pending.resolve(code)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the callback server is running.
|
||||
* If strictPort is true, requires binding on the configured callback port.
|
||||
* If strictPort is false, asks the OS for an available local port.
|
||||
*/
|
||||
export async function ensureCallbackServer(options: EnsureCallbackServerOptions = {}): Promise<void> {
|
||||
while (bindingPromise) {
|
||||
await bindingPromise
|
||||
}
|
||||
|
||||
const operation = ensureCallbackServerLocked(options)
|
||||
bindingPromise = operation
|
||||
try {
|
||||
await operation
|
||||
} finally {
|
||||
if (bindingPromise === operation) {
|
||||
bindingPromise = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCallbackServerLocked(options: EnsureCallbackServerOptions = {}): Promise<void> {
|
||||
const requiredPort = options.port ?? getConfiguredOAuthCallbackPort()
|
||||
const strictPort = options.strictPort === true
|
||||
const requestedHost = options.callbackHost ?? DEFAULT_OAUTH_CALLBACK_HOST
|
||||
const rawRequestedPath = options.callbackPath ?? DEFAULT_OAUTH_CALLBACK_PATH
|
||||
const requestedPath = rawRequestedPath.startsWith("/") ? rawRequestedPath : `/${rawRequestedPath}`
|
||||
if (options.reserveState && !options.oauthState) {
|
||||
throw new Error("OAuth callback reservation requires an oauthState")
|
||||
}
|
||||
let reservedState: string | undefined
|
||||
|
||||
const previousServer = server
|
||||
const needsStrictRebind = Boolean(previousServer && strictPort && getOAuthCallbackPort() !== requiredPort)
|
||||
const needsHostSwitch = Boolean(previousServer && callbackServerHost !== requestedHost)
|
||||
const needsPathSwitch = Boolean(previousServer && getOAuthCallbackPath() !== requestedPath)
|
||||
|
||||
if (previousServer) {
|
||||
if (!needsStrictRebind && !needsHostSwitch) {
|
||||
if (needsPathSwitch) {
|
||||
if (pendingAuths.size > 0 || reservedAuthStates.size > 0) {
|
||||
throw new Error(
|
||||
`OAuth callback server is using path ${getOAuthCallbackPath()}, but callback path ${requestedPath} is required and cannot be switched while authorizations are pending`
|
||||
)
|
||||
}
|
||||
setOAuthCallbackPath(requestedPath)
|
||||
}
|
||||
if (options.reserveState && options.oauthState) {
|
||||
reservedAuthStates.add(options.oauthState)
|
||||
reservedState = options.oauthState
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingAuths.size > 0 || reservedAuthStates.size > 0) {
|
||||
throw new Error(
|
||||
`OAuth callback server is running on ${callbackServerHost}:${getOAuthCallbackPort()}, but strict callback endpoint ${requestedHost}:${requiredPort} is required and cannot be switched while authorizations are pending`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const candidateServer = createServer(handleRequest)
|
||||
const listenPort = strictPort ? requiredPort : 0
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
candidateServer.once("error", (err) => {
|
||||
reject(err)
|
||||
})
|
||||
|
||||
candidateServer.listen(listenPort, requestedHost, () => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
if (strictPort) {
|
||||
setOAuthCallbackPort(requiredPort)
|
||||
} else {
|
||||
const address = candidateServer.address()
|
||||
if (!address || typeof address === "string" || typeof address.port !== "number") {
|
||||
throw new Error("OAuth callback server did not report an assigned port")
|
||||
}
|
||||
setOAuthCallbackPort(address.port)
|
||||
}
|
||||
|
||||
if (previousServer && (needsStrictRebind || needsHostSwitch)) {
|
||||
await new Promise<void>((resolve) => {
|
||||
previousServer.close(() => resolve())
|
||||
})
|
||||
}
|
||||
|
||||
callbackServerHost = requestedHost
|
||||
setOAuthCallbackPath(requestedPath)
|
||||
server = candidateServer
|
||||
if (options.reserveState && options.oauthState) {
|
||||
reservedAuthStates.add(options.oauthState)
|
||||
reservedState = options.oauthState
|
||||
}
|
||||
server.unref()
|
||||
} catch (error) {
|
||||
if (reservedState) {
|
||||
reservedAuthStates.delete(reservedState)
|
||||
}
|
||||
const nodeError = error as NodeJS.ErrnoException
|
||||
await new Promise<void>((resolve) => {
|
||||
candidateServer.close(() => resolve())
|
||||
})
|
||||
|
||||
if (strictPort && nodeError.code === "EADDRINUSE") {
|
||||
throw new Error(
|
||||
`OAuth callback port ${requiredPort} is already in use. Pre-registered OAuth clients require an exact redirect URI; set MCP_OAUTH_CALLBACK_PORT to your registered port or free port ${requiredPort}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function reserveCallbackServer(oauthState: string): void {
|
||||
reservedAuthStates.add(oauthState)
|
||||
}
|
||||
|
||||
export function releaseCallbackServer(oauthState: string): void {
|
||||
reservedAuthStates.delete(oauthState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a callback with the given OAuth state.
|
||||
* Returns a promise that resolves with the authorization code.
|
||||
*/
|
||||
export function waitForCallback(oauthState: string): Promise<string> {
|
||||
reservedAuthStates.delete(oauthState)
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (pendingAuths.has(oauthState)) {
|
||||
pendingAuths.delete(oauthState)
|
||||
reject(new Error("OAuth callback timeout - authorization took too long"))
|
||||
}
|
||||
}, CALLBACK_TIMEOUT_MS)
|
||||
|
||||
pendingAuths.set(oauthState, { resolve, reject, timeout })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending authorization by state.
|
||||
*/
|
||||
export function cancelPendingCallback(oauthState: string): void {
|
||||
reservedAuthStates.delete(oauthState)
|
||||
const pending = pendingAuths.get(oauthState)
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(oauthState)
|
||||
pending.reject(new Error("Authorization cancelled"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the callback server and reject all pending authorizations.
|
||||
*/
|
||||
export async function stopCallbackServer(): Promise<void> {
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
server!.close(() => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
server = undefined
|
||||
}
|
||||
|
||||
setOAuthCallbackPort(getConfiguredOAuthCallbackPort())
|
||||
callbackServerHost = DEFAULT_OAUTH_CALLBACK_HOST
|
||||
setOAuthCallbackPath(DEFAULT_OAUTH_CALLBACK_PATH)
|
||||
|
||||
// Reject all pending auths (defer to allow any pending operations to complete)
|
||||
const pendingList = Array.from(pendingAuths.entries())
|
||||
pendingAuths.clear()
|
||||
reservedAuthStates.clear()
|
||||
setTimeout(() => {
|
||||
for (const [, pending] of pendingList) {
|
||||
clearTimeout(pending.timeout)
|
||||
pending.reject(new Error("OAuth callback server stopped"))
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the callback server is running.
|
||||
*/
|
||||
export function isCallbackServerRunning(): boolean {
|
||||
return server !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of pending authorizations.
|
||||
*/
|
||||
export function getPendingAuthCount(): number {
|
||||
return pendingAuths.size
|
||||
}
|
||||
518
agent/extensions/pi-mcp-adapter/mcp-oauth-provider.test.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* Tests for mcp-oauth-provider.ts - OAuth provider implementation
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { existsSync, rmSync, mkdirSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
// Set up isolated temp directory for tests
|
||||
const TEST_DIR = join(tmpdir(), `mcp-oauth-test-${randomBytes(4).toString('hex')}`)
|
||||
process.env.MCP_OAUTH_DIR = TEST_DIR
|
||||
|
||||
import {
|
||||
getOAuthCallbackPath,
|
||||
getOAuthCallbackPort,
|
||||
McpOAuthProvider,
|
||||
setOAuthCallbackPath,
|
||||
setOAuthCallbackPort,
|
||||
type McpOAuthConfig,
|
||||
} from "./mcp-oauth-provider.ts"
|
||||
import { getAuthForUrl, saveAuthEntry, updateOAuthState } from "./mcp-auth.ts"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
|
||||
describe("McpOAuthProvider", () => {
|
||||
const serverName = "test-server"
|
||||
const serverUrl = "https://api.example.com"
|
||||
let redirectCaptured: URL | undefined
|
||||
|
||||
before(() => {
|
||||
// Ensure clean state
|
||||
try {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
after(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
redirectCaptured = undefined
|
||||
})
|
||||
|
||||
function createProvider(config: McpOAuthConfig = {}) {
|
||||
return new McpOAuthProvider(serverName, serverUrl, config, {
|
||||
onRedirect: async (url) => {
|
||||
redirectCaptured = url
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("redirectUrl", () => {
|
||||
it("should return the correct redirect URL", () => {
|
||||
const provider = createProvider()
|
||||
assert.strictEqual(
|
||||
provider.redirectUrl,
|
||||
"http://localhost:19876/callback"
|
||||
)
|
||||
})
|
||||
|
||||
it("should use a configured redirect URI", () => {
|
||||
const provider = createProvider({ redirectUri: "http://localhost:3118/slack/callback" })
|
||||
assert.strictEqual(provider.redirectUrl, "http://localhost:3118/slack/callback")
|
||||
})
|
||||
|
||||
it("should snapshot generated redirect URI at construction", () => {
|
||||
const originalPort = getOAuthCallbackPort()
|
||||
const originalPath = getOAuthCallbackPath()
|
||||
setOAuthCallbackPort(41234)
|
||||
setOAuthCallbackPath("/snapshot/callback")
|
||||
|
||||
try {
|
||||
const provider = createProvider()
|
||||
setOAuthCallbackPort(52345)
|
||||
setOAuthCallbackPath("/changed/callback")
|
||||
|
||||
assert.strictEqual(provider.redirectUrl, "http://localhost:41234/snapshot/callback")
|
||||
assert.deepStrictEqual(provider.clientMetadata.redirect_uris, ["http://localhost:41234/snapshot/callback"])
|
||||
} finally {
|
||||
setOAuthCallbackPort(originalPort)
|
||||
setOAuthCallbackPath(originalPath)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("clientMetadata", () => {
|
||||
it("should return correct metadata for public client", () => {
|
||||
const provider = createProvider()
|
||||
const metadata = provider.clientMetadata
|
||||
|
||||
assert.deepStrictEqual(metadata.redirect_uris, ["http://localhost:19876/callback"])
|
||||
assert.strictEqual(metadata.client_name, "Pi Coding Agent")
|
||||
assert.strictEqual(metadata.client_uri, "https://github.com/nicobailon/pi-mcp-adapter")
|
||||
assert.deepStrictEqual(metadata.grant_types, ["authorization_code", "refresh_token"])
|
||||
assert.deepStrictEqual(metadata.response_types, ["code"])
|
||||
assert.strictEqual(metadata.token_endpoint_auth_method, "none")
|
||||
})
|
||||
|
||||
it("should return correct metadata for confidential client", () => {
|
||||
const provider = createProvider({ clientSecret: "secret" })
|
||||
const metadata = provider.clientMetadata
|
||||
|
||||
assert.strictEqual(metadata.token_endpoint_auth_method, "client_secret_post")
|
||||
})
|
||||
|
||||
it("should use configured redirect URI and client metadata", () => {
|
||||
const provider = createProvider({
|
||||
redirectUri: "http://localhost:3118/slack/callback",
|
||||
clientName: "Slack MCP",
|
||||
clientUri: "https://example.com/slack-mcp",
|
||||
})
|
||||
const metadata = provider.clientMetadata
|
||||
|
||||
assert.deepStrictEqual(metadata.redirect_uris, ["http://localhost:3118/slack/callback"])
|
||||
assert.strictEqual(metadata.client_name, "Slack MCP")
|
||||
assert.strictEqual(metadata.client_uri, "https://example.com/slack-mcp")
|
||||
})
|
||||
|
||||
it("should use configured client name for client_credentials", () => {
|
||||
const provider = createProvider({
|
||||
grantType: "client_credentials",
|
||||
clientName: "Service MCP",
|
||||
})
|
||||
const metadata = provider.clientMetadata
|
||||
|
||||
assert.strictEqual(metadata.client_name, "Service MCP")
|
||||
assert.deepStrictEqual(metadata.redirect_uris, [])
|
||||
assert.deepStrictEqual(metadata.grant_types, ["client_credentials"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("clientInformation", () => {
|
||||
it("should return config clientId when provided", async () => {
|
||||
const provider = createProvider({ clientId: "config-client", clientSecret: "config-secret" })
|
||||
const info = await provider.clientInformation()
|
||||
|
||||
assert.strictEqual(info?.client_id, "config-client")
|
||||
assert.strictEqual(info?.client_secret, "config-secret")
|
||||
})
|
||||
|
||||
it("should return stored client info when no config", async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
// Save client info directly
|
||||
saveAuthEntry(serverName, {
|
||||
clientInfo: {
|
||||
clientId: "stored-client",
|
||||
clientSecret: "stored-secret",
|
||||
clientIdIssuedAt: Math.floor(Date.now() / 1000),
|
||||
clientSecretExpiresAt: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
serverUrl,
|
||||
}, serverUrl)
|
||||
|
||||
const info = await provider.clientInformation()
|
||||
assert.strictEqual(info?.client_id, "stored-client")
|
||||
assert.strictEqual(info?.client_secret, "stored-secret")
|
||||
})
|
||||
|
||||
it("should return undefined when URL doesn't match", async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
// Save client info with different URL
|
||||
saveAuthEntry(serverName, {
|
||||
clientInfo: {
|
||||
clientId: "stored-client",
|
||||
clientSecret: "stored-secret",
|
||||
},
|
||||
serverUrl: "https://different.com",
|
||||
}, "https://different.com")
|
||||
|
||||
const info = await provider.clientInformation()
|
||||
assert.strictEqual(info, undefined)
|
||||
})
|
||||
|
||||
it("should return undefined when client secret expired", async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
// Save client info with expired secret
|
||||
saveAuthEntry(serverName, {
|
||||
clientInfo: {
|
||||
clientId: "stored-client",
|
||||
clientSecret: "stored-secret",
|
||||
clientSecretExpiresAt: 1, // Expired in 1970
|
||||
},
|
||||
serverUrl,
|
||||
}, serverUrl)
|
||||
|
||||
const info = await provider.clientInformation()
|
||||
assert.strictEqual(info, undefined)
|
||||
})
|
||||
|
||||
it("should prefer config over stored", async () => {
|
||||
const provider = createProvider({ clientId: "config-client" })
|
||||
|
||||
// Save different client info
|
||||
saveAuthEntry(serverName, {
|
||||
clientInfo: {
|
||||
clientId: "stored-client",
|
||||
clientSecret: "stored-secret",
|
||||
},
|
||||
serverUrl,
|
||||
}, serverUrl)
|
||||
|
||||
const info = await provider.clientInformation()
|
||||
assert.strictEqual(info?.client_id, "config-client")
|
||||
})
|
||||
})
|
||||
|
||||
describe("saveClientInformation", () => {
|
||||
it("should save client information", async () => {
|
||||
const provider = createProvider()
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
const info: OAuthClientInformationFull = {
|
||||
client_id: "new-client",
|
||||
client_secret: "new-secret",
|
||||
redirect_uris: ["http://localhost:3118/callback"],
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
client_secret_expires_at: futureTime,
|
||||
}
|
||||
|
||||
await provider.saveClientInformation(info)
|
||||
|
||||
const storedInfo = await provider.clientInformation()
|
||||
assert.strictEqual(storedInfo?.client_id, "new-client")
|
||||
assert.strictEqual(storedInfo?.client_secret, "new-secret")
|
||||
assert.deepStrictEqual(getAuthForUrl(serverName, serverUrl)?.clientInfo?.redirectUris, ["http://localhost:3118/callback"])
|
||||
})
|
||||
|
||||
it("should save the current redirect URL when registration omits redirect_uris", async () => {
|
||||
const provider = new McpOAuthProvider("redirect-fallback", serverUrl, { redirectUri: "http://localhost:3118/custom" }, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
|
||||
await provider.saveClientInformation({
|
||||
client_id: "fallback-client",
|
||||
client_secret: "fallback-secret",
|
||||
} as OAuthClientInformationFull)
|
||||
|
||||
assert.deepStrictEqual(getAuthForUrl("redirect-fallback", serverUrl)?.clientInfo?.redirectUris, ["http://localhost:3118/custom"])
|
||||
})
|
||||
|
||||
it("should return stored dynamic client info even when redirect URIs are stale", async () => {
|
||||
const provider = new McpOAuthProvider("stale-redirect-client", serverUrl, { redirectUri: "http://localhost:3118/current" }, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
saveAuthEntry("stale-redirect-client", {
|
||||
clientInfo: {
|
||||
clientId: "stored-client",
|
||||
clientSecret: "stored-secret",
|
||||
redirectUris: ["http://localhost:19876/callback"],
|
||||
},
|
||||
serverUrl,
|
||||
}, serverUrl)
|
||||
|
||||
const info = await provider.clientInformation()
|
||||
assert.strictEqual(info?.client_id, "stored-client")
|
||||
assert.strictEqual(info?.client_secret, "stored-secret")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tokens / saveTokens", () => {
|
||||
it("should save and retrieve tokens", async () => {
|
||||
const provider = createProvider()
|
||||
const tokens: OAuthTokens = {
|
||||
access_token: "access-123",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "refresh-456",
|
||||
expires_in: 3600,
|
||||
scope: "read write",
|
||||
}
|
||||
|
||||
await provider.saveTokens(tokens)
|
||||
const stored = await provider.tokens()
|
||||
|
||||
assert.strictEqual(stored?.access_token, "access-123")
|
||||
assert.strictEqual(stored?.refresh_token, "refresh-456")
|
||||
assert.strictEqual(stored?.scope, "read write")
|
||||
})
|
||||
|
||||
it("should calculate expires_in from stored expiresAt", async () => {
|
||||
const provider = createProvider()
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
|
||||
await provider.saveTokens({
|
||||
access_token: "access",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
})
|
||||
|
||||
const stored = await provider.tokens()
|
||||
assert.ok(stored?.expires_in !== undefined)
|
||||
assert.ok(stored!.expires_in! > 0)
|
||||
assert.ok(stored!.expires_in! <= 3600)
|
||||
})
|
||||
|
||||
it("should return undefined when URL doesn't match", async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
// Save tokens with different URL
|
||||
saveAuthEntry(serverName, {
|
||||
tokens: {
|
||||
accessToken: "token",
|
||||
},
|
||||
serverUrl: "https://different.com",
|
||||
}, "https://different.com")
|
||||
|
||||
const stored = await provider.tokens()
|
||||
assert.strictEqual(stored, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("redirectToAuthorization", () => {
|
||||
it("should call onRedirect with URL when a flow is in progress", async () => {
|
||||
const provider = new McpOAuthProvider("redirect-with-state", serverUrl, {}, {
|
||||
onRedirect: async (url) => {
|
||||
redirectCaptured = url
|
||||
},
|
||||
})
|
||||
await updateOAuthState("redirect-with-state", "state-abc", serverUrl)
|
||||
const testUrl = new URL("https://example.com/auth")
|
||||
|
||||
await provider.redirectToAuthorization(testUrl)
|
||||
|
||||
assert.strictEqual(redirectCaptured, testUrl)
|
||||
})
|
||||
|
||||
it("should throw UnauthorizedError when no flow is in progress", async () => {
|
||||
const provider = new McpOAuthProvider("redirect-no-state", serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
async () => provider.redirectToAuthorization(new URL("https://example.com/auth")),
|
||||
(err: unknown) => err instanceof UnauthorizedError && /Re-authentication required/.test((err as Error).message),
|
||||
)
|
||||
})
|
||||
|
||||
it("should ignore OAuth state saved for a different server URL before redirecting", async () => {
|
||||
let redirected = false
|
||||
const provider = new McpOAuthProvider("redirect-url-bound", serverUrl, {}, {
|
||||
onRedirect: async () => {
|
||||
redirected = true
|
||||
},
|
||||
})
|
||||
saveAuthEntry("redirect-url-bound", {
|
||||
oauthState: "stale-state",
|
||||
serverUrl: "https://different.example.com",
|
||||
}, "https://different.example.com")
|
||||
|
||||
await assert.rejects(
|
||||
async () => provider.redirectToAuthorization(new URL("https://example.com/auth")),
|
||||
(err: unknown) => err instanceof UnauthorizedError && /Re-authentication required/.test((err as Error).message),
|
||||
)
|
||||
assert.strictEqual(redirected, false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("codeVerifier / saveCodeVerifier", () => {
|
||||
it("should save and retrieve code verifier", async () => {
|
||||
const provider = new McpOAuthProvider("code-verifier-test", serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
|
||||
await provider.saveCodeVerifier("verifier-abc-123")
|
||||
|
||||
const verifier = await provider.codeVerifier()
|
||||
assert.strictEqual(verifier, "verifier-abc-123")
|
||||
assert.strictEqual(getAuthForUrl("code-verifier-test", serverUrl)?.codeVerifier, "verifier-abc-123")
|
||||
})
|
||||
|
||||
it("should throw when no code verifier", async () => {
|
||||
const provider = new McpOAuthProvider("code-verifier-throw", serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
async () => provider.codeVerifier(),
|
||||
/No code verifier saved/
|
||||
)
|
||||
})
|
||||
|
||||
it("should ignore code verifiers saved for a different server URL", async () => {
|
||||
const provider = new McpOAuthProvider("code-verifier-url-bound", serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
saveAuthEntry("code-verifier-url-bound", {
|
||||
codeVerifier: "stale-verifier",
|
||||
serverUrl: "https://different.example.com",
|
||||
}, "https://different.example.com")
|
||||
|
||||
await assert.rejects(
|
||||
async () => provider.codeVerifier(),
|
||||
/No code verifier saved/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("state / saveState", () => {
|
||||
it("should save and retrieve state", async () => {
|
||||
const provider = new McpOAuthProvider("state-test-save", serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
|
||||
await provider.saveState("state-xyz-789")
|
||||
|
||||
const state = await provider.state()
|
||||
assert.strictEqual(state, "state-xyz-789")
|
||||
assert.strictEqual(getAuthForUrl("state-test-save", serverUrl)?.oauthState, "state-xyz-789")
|
||||
})
|
||||
|
||||
it("should throw UnauthorizedError when no state is saved", async () => {
|
||||
const provider = new McpOAuthProvider("state-test-throw", serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
async () => provider.state(),
|
||||
(err: unknown) => err instanceof UnauthorizedError && /Re-authentication required/.test((err as Error).message),
|
||||
)
|
||||
})
|
||||
|
||||
it("should ignore OAuth state saved for a different server URL", async () => {
|
||||
const provider = new McpOAuthProvider("state-url-bound", serverUrl, {}, {
|
||||
onRedirect: async () => {},
|
||||
})
|
||||
saveAuthEntry("state-url-bound", {
|
||||
oauthState: "stale-state",
|
||||
serverUrl: "https://different.example.com",
|
||||
}, "https://different.example.com")
|
||||
|
||||
await assert.rejects(
|
||||
async () => provider.state(),
|
||||
(err: unknown) => err instanceof UnauthorizedError && /Re-authentication required/.test((err as Error).message),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("invalidateCredentials", () => {
|
||||
it("should remove all credentials when type is 'all'", async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
await provider.saveTokens({
|
||||
access_token: "token",
|
||||
token_type: "Bearer",
|
||||
})
|
||||
await provider.saveClientInformation({
|
||||
client_id: "client",
|
||||
client_secret: "secret",
|
||||
redirect_uris: ["http://localhost/callback"],
|
||||
})
|
||||
|
||||
await provider.invalidateCredentials("all")
|
||||
|
||||
assert.strictEqual(await provider.tokens(), undefined)
|
||||
assert.strictEqual(await provider.clientInformation(), undefined)
|
||||
})
|
||||
|
||||
it("should only remove tokens when type is 'tokens'", async () => {
|
||||
const provider = createProvider()
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
|
||||
await provider.saveTokens({
|
||||
access_token: "token",
|
||||
token_type: "Bearer",
|
||||
})
|
||||
await provider.saveClientInformation({
|
||||
client_id: "client",
|
||||
client_secret: "secret",
|
||||
redirect_uris: ["http://localhost/callback"],
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
client_secret_expires_at: futureTime,
|
||||
})
|
||||
|
||||
await provider.invalidateCredentials("tokens")
|
||||
|
||||
assert.strictEqual(await provider.tokens(), undefined)
|
||||
const clientInfo = await provider.clientInformation()
|
||||
assert.strictEqual(clientInfo?.client_id, "client")
|
||||
})
|
||||
|
||||
it("should only remove client info when type is 'client'", async () => {
|
||||
const provider = createProvider()
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
|
||||
await provider.saveTokens({
|
||||
access_token: "token",
|
||||
token_type: "Bearer",
|
||||
})
|
||||
await provider.saveClientInformation({
|
||||
client_id: "client",
|
||||
client_secret: "secret",
|
||||
redirect_uris: ["http://localhost/callback"],
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
client_secret_expires_at: futureTime,
|
||||
})
|
||||
|
||||
await provider.invalidateCredentials("client")
|
||||
|
||||
const tokens = await provider.tokens()
|
||||
assert.strictEqual(tokens?.access_token, "token")
|
||||
assert.strictEqual(await provider.clientInformation(), undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
369
agent/extensions/pi-mcp-adapter/mcp-oauth-provider.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* MCP OAuth Provider
|
||||
*
|
||||
* Implementation of the MCP SDK's OAuthClientProvider interface.
|
||||
* Handles OAuth client registration, token storage, and authorization redirection.
|
||||
*/
|
||||
|
||||
import type { AddClientAuthentication, OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type {
|
||||
OAuthClientMetadata,
|
||||
OAuthTokens,
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
import {
|
||||
getAuthForUrl,
|
||||
updateTokens,
|
||||
updateClientInfo,
|
||||
updateCodeVerifier,
|
||||
updateOAuthState,
|
||||
clearAllCredentials,
|
||||
clearClientInfo,
|
||||
clearTokens,
|
||||
type StoredTokens,
|
||||
type StoredClientInfo,
|
||||
} from "./mcp-auth.ts"
|
||||
|
||||
// Callback server configuration
|
||||
const DEFAULT_OAUTH_CALLBACK_PORT = 19876
|
||||
const DEFAULT_OAUTH_CALLBACK_PATH = "/callback"
|
||||
|
||||
let configuredOAuthCallbackPort = DEFAULT_OAUTH_CALLBACK_PORT
|
||||
|
||||
if (process.env.MCP_OAUTH_CALLBACK_PORT) {
|
||||
const parsedPort = Number.parseInt(process.env.MCP_OAUTH_CALLBACK_PORT, 10)
|
||||
if (Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
|
||||
configuredOAuthCallbackPort = parsedPort
|
||||
}
|
||||
}
|
||||
|
||||
let oauthCallbackPort = configuredOAuthCallbackPort
|
||||
let oauthCallbackPath = DEFAULT_OAUTH_CALLBACK_PATH
|
||||
|
||||
export function getConfiguredOAuthCallbackPort(): number {
|
||||
return configuredOAuthCallbackPort
|
||||
}
|
||||
|
||||
export function getOAuthCallbackPort(): number {
|
||||
return oauthCallbackPort
|
||||
}
|
||||
|
||||
export function setOAuthCallbackPort(port: number): void {
|
||||
oauthCallbackPort = port
|
||||
}
|
||||
|
||||
export function getOAuthCallbackPath(): string {
|
||||
return oauthCallbackPath
|
||||
}
|
||||
|
||||
export function setOAuthCallbackPath(path: string): void {
|
||||
oauthCallbackPath = path.startsWith("/") ? path : `/${path}`
|
||||
}
|
||||
|
||||
/** Configuration options for OAuth */
|
||||
export interface McpOAuthConfig {
|
||||
grantType?: "authorization_code" | "client_credentials"
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
scope?: string
|
||||
redirectUri?: string
|
||||
clientName?: string
|
||||
clientUri?: string
|
||||
}
|
||||
|
||||
/** Callbacks for OAuth flow interactions */
|
||||
export interface McpOAuthCallbacks {
|
||||
onRedirect: (url: URL) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth provider implementation for MCP servers.
|
||||
* Implements the OAuthClientProvider interface from the MCP SDK.
|
||||
*/
|
||||
export class McpOAuthProvider implements OAuthClientProvider {
|
||||
private readonly redirectUrlSnapshot: string | undefined
|
||||
|
||||
constructor(
|
||||
private serverName: string,
|
||||
private serverUrl: string,
|
||||
private config: McpOAuthConfig,
|
||||
private callbacks: McpOAuthCallbacks,
|
||||
) {
|
||||
this.redirectUrlSnapshot = config.grantType === "client_credentials"
|
||||
? undefined
|
||||
: config.redirectUri ?? `http://localhost:${getOAuthCallbackPort()}${getOAuthCallbackPath()}`
|
||||
}
|
||||
|
||||
private get usesClientCredentials(): boolean {
|
||||
return this.config.grantType === "client_credentials"
|
||||
}
|
||||
|
||||
/**
|
||||
* The redirect URL for OAuth callbacks.
|
||||
* This must match the redirect_uri in client metadata.
|
||||
*/
|
||||
get redirectUrl(): string | undefined {
|
||||
return this.redirectUrlSnapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Client metadata for dynamic registration.
|
||||
* Describes this client to the OAuth authorization server.
|
||||
*/
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
if (this.usesClientCredentials) {
|
||||
return {
|
||||
client_name: this.config.clientName ?? "Pi Coding Agent",
|
||||
client_uri: this.config.clientUri ?? "https://github.com/nicobailon/pi-mcp-adapter",
|
||||
redirect_uris: [],
|
||||
grant_types: ["client_credentials"],
|
||||
token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
|
||||
}
|
||||
}
|
||||
|
||||
const redirectUrl = this.redirectUrl
|
||||
if (!redirectUrl) {
|
||||
throw new Error("redirectUrl is required for authorization_code flow")
|
||||
}
|
||||
|
||||
return {
|
||||
redirect_uris: [redirectUrl],
|
||||
client_name: this.config.clientName ?? "Pi Coding Agent",
|
||||
client_uri: this.config.clientUri ?? "https://github.com/nicobailon/pi-mcp-adapter",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
|
||||
...(this.config.scope !== undefined ? { scope: this.config.scope } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client information (for pre-registered or dynamically registered clients).
|
||||
* Returns undefined if no client info exists or if the server URL has changed.
|
||||
*/
|
||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
// Check config first (pre-registered client)
|
||||
if (this.config.clientId) {
|
||||
return {
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
// Check stored client info (from dynamic registration)
|
||||
// Use getAuthForUrl to validate credentials are for the current server URL
|
||||
const entry = await getAuthForUrl(this.serverName, this.serverUrl)
|
||||
if (entry?.clientInfo) {
|
||||
// Check if client secret has expired
|
||||
if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
client_id: entry.clientInfo.clientId,
|
||||
client_secret: entry.clientInfo.clientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
// No client info or URL changed - will trigger dynamic registration
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Save client information from dynamic registration.
|
||||
*/
|
||||
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||
const redirectUris = info.redirect_uris ?? (this.redirectUrl ? [this.redirectUrl] : undefined)
|
||||
const clientInfo: StoredClientInfo = {
|
||||
clientId: info.client_id,
|
||||
clientSecret: info.client_secret,
|
||||
clientIdIssuedAt: info.client_id_issued_at,
|
||||
clientSecretExpiresAt: info.client_secret_expires_at,
|
||||
redirectUris,
|
||||
}
|
||||
updateClientInfo(this.serverName, clientInfo, this.serverUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored OAuth tokens.
|
||||
* Returns undefined if no tokens exist or if the server URL has changed.
|
||||
*/
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
// Use getAuthForUrl to validate tokens are for the current server URL
|
||||
const entry = await getAuthForUrl(this.serverName, this.serverUrl)
|
||||
if (!entry?.tokens) return undefined
|
||||
|
||||
return {
|
||||
access_token: entry.tokens.accessToken,
|
||||
token_type: "Bearer",
|
||||
refresh_token: entry.tokens.refreshToken,
|
||||
expires_in: entry.tokens.expiresAt
|
||||
? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000))
|
||||
: undefined,
|
||||
scope: entry.tokens.scope,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save OAuth tokens.
|
||||
*/
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
const storedTokens: StoredTokens = {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
|
||||
scope: tokens.scope,
|
||||
}
|
||||
updateTokens(this.serverName, storedTokens, this.serverUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the user to the authorization URL.
|
||||
* This opens the browser for the user to authenticate.
|
||||
*
|
||||
* Throws UnauthorizedError when called outside of a user-initiated flow
|
||||
* (no oauthState saved by startAuth). That path is reached when the SDK
|
||||
* falls through from a failed refresh into a fresh authorization_code
|
||||
* flow, which library hosts cannot complete in-process.
|
||||
*/
|
||||
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
||||
if (this.usesClientCredentials) {
|
||||
throw new Error("redirectToAuthorization is not used for client_credentials flow")
|
||||
}
|
||||
// No saved oauthState means we're on the post-refresh authorize fallback.
|
||||
const entry = await getAuthForUrl(this.serverName, this.serverUrl)
|
||||
if (!entry?.oauthState) {
|
||||
throw new UnauthorizedError(
|
||||
`Re-authentication required for MCP server: ${this.serverName}`,
|
||||
)
|
||||
}
|
||||
// URL is passed to callback, not logged (may contain sensitive params)
|
||||
await this.callbacks.onRedirect(authorizationUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the PKCE code verifier.
|
||||
*/
|
||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||
updateCodeVerifier(this.serverName, codeVerifier, this.serverUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stored PKCE code verifier.
|
||||
* @throws Error if no code verifier is stored
|
||||
*/
|
||||
async codeVerifier(): Promise<string> {
|
||||
if (this.usesClientCredentials) {
|
||||
throw new Error("codeVerifier is not used for client_credentials flow")
|
||||
}
|
||||
const entry = await getAuthForUrl(this.serverName, this.serverUrl)
|
||||
if (!entry?.codeVerifier) {
|
||||
throw new Error(`No code verifier saved for MCP server: ${this.serverName}`)
|
||||
}
|
||||
return entry.codeVerifier
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the OAuth state parameter for CSRF protection.
|
||||
*/
|
||||
async saveState(state: string): Promise<void> {
|
||||
updateOAuthState(this.serverName, state, this.serverUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stored OAuth state parameter.
|
||||
* @throws UnauthorizedError if no flow is in progress (see redirectToAuthorization)
|
||||
*/
|
||||
async state(): Promise<string> {
|
||||
if (this.usesClientCredentials) {
|
||||
throw new Error("state is not used for client_credentials flow")
|
||||
}
|
||||
const entry = await getAuthForUrl(this.serverName, this.serverUrl)
|
||||
if (!entry?.oauthState) {
|
||||
throw new UnauthorizedError(
|
||||
`Re-authentication required for MCP server: ${this.serverName}`,
|
||||
)
|
||||
}
|
||||
return entry.oauthState
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate credentials when authentication fails.
|
||||
* Clears tokens, client info, or all credentials based on the type.
|
||||
*/
|
||||
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
|
||||
switch (type) {
|
||||
case "all":
|
||||
clearAllCredentials(this.serverName)
|
||||
break
|
||||
case "client":
|
||||
clearClientInfo(this.serverName)
|
||||
break
|
||||
case "tokens":
|
||||
clearTokens(this.serverName)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds configured authorization-code scope without replacing the SDK's
|
||||
* default token endpoint authentication behavior.
|
||||
*/
|
||||
addClientAuthentication: AddClientAuthentication = async (headers, params, _url, metadata) => {
|
||||
if (params.get("grant_type") === "authorization_code" && !params.has("scope") && this.config.scope) {
|
||||
params.set("scope", this.config.scope)
|
||||
}
|
||||
|
||||
const clientInfo = await this.clientInformation()
|
||||
if (!clientInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []
|
||||
const hasClientSecret = clientInfo.client_secret !== undefined
|
||||
let authMethod: "client_secret_basic" | "client_secret_post" | "none"
|
||||
|
||||
if (supportedMethods.length === 0) {
|
||||
authMethod = hasClientSecret ? "client_secret_post" : "none"
|
||||
} else if (hasClientSecret && supportedMethods.includes("client_secret_basic")) {
|
||||
authMethod = "client_secret_basic"
|
||||
} else if (hasClientSecret && supportedMethods.includes("client_secret_post")) {
|
||||
authMethod = "client_secret_post"
|
||||
} else if (supportedMethods.includes("none")) {
|
||||
authMethod = "none"
|
||||
} else {
|
||||
authMethod = hasClientSecret ? "client_secret_post" : "none"
|
||||
}
|
||||
|
||||
if (authMethod === "client_secret_basic") {
|
||||
if (!clientInfo.client_secret) {
|
||||
throw new Error("client_secret_basic authentication requires a client_secret")
|
||||
}
|
||||
headers.set("Authorization", `Basic ${Buffer.from(`${clientInfo.client_id}:${clientInfo.client_secret}`).toString("base64")}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!params.has("client_id")) {
|
||||
params.set("client_id", clientInfo.client_id)
|
||||
}
|
||||
if (authMethod === "client_secret_post" && clientInfo.client_secret && !params.has("client_secret")) {
|
||||
params.set("client_secret", clientInfo.client_secret)
|
||||
}
|
||||
}
|
||||
|
||||
prepareTokenRequest(scope?: string): URLSearchParams | undefined {
|
||||
if (!this.usesClientCredentials) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ grant_type: "client_credentials" })
|
||||
const requestedScope = scope ?? this.config.scope
|
||||
if (requestedScope) {
|
||||
params.set("scope", requestedScope)
|
||||
}
|
||||
return params
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_OAUTH_CALLBACK_PORT, DEFAULT_OAUTH_CALLBACK_PATH }
|
||||
829
agent/extensions/pi-mcp-adapter/mcp-panel.ts
Normal file
@@ -0,0 +1,829 @@
|
||||
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
|
||||
import { isToolExcluded } from "./types.ts";
|
||||
import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerProvenance } from "./types.ts";
|
||||
import { resourceNameToToolName } from "./resource-tools.ts";
|
||||
import type { MetadataCache, ServerCacheEntry, CachedTool } from "./metadata-cache.ts";
|
||||
|
||||
interface PanelTheme {
|
||||
border: string;
|
||||
title: string;
|
||||
selected: string;
|
||||
direct: string;
|
||||
needsAuth: string;
|
||||
placeholder: string;
|
||||
description: string;
|
||||
hint: string;
|
||||
confirm: string;
|
||||
cancel: string;
|
||||
}
|
||||
|
||||
const DEFAULT_THEME: PanelTheme = {
|
||||
border: "2",
|
||||
title: "2",
|
||||
selected: "36",
|
||||
direct: "32",
|
||||
needsAuth: "33",
|
||||
placeholder: "2;3",
|
||||
description: "2",
|
||||
hint: "2",
|
||||
confirm: "32",
|
||||
cancel: "31",
|
||||
};
|
||||
|
||||
function fg(code: string, text: string): string {
|
||||
if (!code) return text;
|
||||
return `\x1b[${code}m${text}\x1b[0m`;
|
||||
}
|
||||
|
||||
const RAINBOW_COLORS = [
|
||||
"38;2;178;129;214",
|
||||
"38;2;215;135;175",
|
||||
"38;2;254;188;56",
|
||||
"38;2;228;192;15",
|
||||
"38;2;137;210;129",
|
||||
"38;2;0;175;175",
|
||||
"38;2;23;143;185",
|
||||
];
|
||||
|
||||
function rainbowProgress(filled: number, total: number): string {
|
||||
const dots: string[] = [];
|
||||
for (let i = 0; i < total; i++) {
|
||||
const color = RAINBOW_COLORS[i % RAINBOW_COLORS.length];
|
||||
dots.push(fg(color, i < filled ? "●" : "○"));
|
||||
}
|
||||
return dots.join(" ");
|
||||
}
|
||||
|
||||
function fuzzyScore(query: string, text: string): number {
|
||||
const lq = query.toLowerCase();
|
||||
const lt = text.toLowerCase();
|
||||
if (lt.includes(lq)) return 100 + (lq.length / lt.length) * 50;
|
||||
let score = 0;
|
||||
let qi = 0;
|
||||
let consecutive = 0;
|
||||
for (let i = 0; i < lt.length && qi < lq.length; i++) {
|
||||
if (lt[i] === lq[qi]) {
|
||||
score += 10 + consecutive;
|
||||
consecutive += 5;
|
||||
qi++;
|
||||
} else {
|
||||
consecutive = 0;
|
||||
}
|
||||
}
|
||||
return qi === lq.length ? score : 0;
|
||||
}
|
||||
|
||||
function estimateTokens(tool: CachedTool): number {
|
||||
const schemaLen = JSON.stringify(tool.inputSchema ?? {}).length;
|
||||
const descLen = tool.description?.length ?? 0;
|
||||
return Math.ceil((tool.name.length + descLen + schemaLen) / 4) + 10;
|
||||
}
|
||||
|
||||
type ConnectionStatus = "connected" | "idle" | "failed" | "needs-auth" | "connecting";
|
||||
|
||||
interface ToolState {
|
||||
name: string;
|
||||
description: string;
|
||||
isDirect: boolean;
|
||||
wasDirect: boolean;
|
||||
estimatedTokens: number;
|
||||
}
|
||||
|
||||
interface ServerState {
|
||||
name: string;
|
||||
expanded: boolean;
|
||||
source: "user" | "project" | "import";
|
||||
importKind?: string;
|
||||
excludeTools?: string[];
|
||||
exposeResources: boolean;
|
||||
connectionStatus: ConnectionStatus;
|
||||
tools: ToolState[];
|
||||
hasCachedData: boolean;
|
||||
}
|
||||
|
||||
interface VisibleItem {
|
||||
type: "server" | "tool";
|
||||
serverIndex: number;
|
||||
toolIndex?: number;
|
||||
}
|
||||
|
||||
class McpPanel {
|
||||
private noticeLines: string[];
|
||||
private prefix: "server" | "none" | "short";
|
||||
private servers: ServerState[] = [];
|
||||
private cursorIndex = 0;
|
||||
private nameQuery = "";
|
||||
private descSearchActive = false;
|
||||
private descQuery = "";
|
||||
private dirty = false;
|
||||
private confirmingDiscard = false;
|
||||
private discardSelected = 1;
|
||||
private importNotice: string | null = null;
|
||||
private authNotice: string | null = null;
|
||||
private authInFlight: string | null = null;
|
||||
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private visibleItems: VisibleItem[] = [];
|
||||
private tui: { requestRender(): void };
|
||||
private t = DEFAULT_THEME;
|
||||
private authOnly: boolean;
|
||||
private keys: PanelKeys;
|
||||
|
||||
private static readonly MAX_VISIBLE = 12;
|
||||
private static readonly INACTIVITY_MS = 60_000;
|
||||
|
||||
constructor(
|
||||
config: McpConfig,
|
||||
cache: MetadataCache | null,
|
||||
provenance: Map<string, ServerProvenance>,
|
||||
private callbacks: McpPanelCallbacks,
|
||||
tui: { requestRender(): void },
|
||||
private done: (result: McpPanelResult) => void,
|
||||
options: { noticeLines?: string[]; authOnly?: boolean; keybindings?: PanelKeybindings } = {},
|
||||
) {
|
||||
this.tui = tui;
|
||||
this.noticeLines = options.noticeLines ?? [];
|
||||
this.authOnly = options.authOnly === true;
|
||||
this.keys = createPanelKeys(options.keybindings);
|
||||
this.prefix = config.settings?.toolPrefix ?? "server";
|
||||
|
||||
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
||||
if (this.authOnly && !callbacks.canAuthenticate(serverName)) continue;
|
||||
const prov = provenance.get(serverName);
|
||||
const serverCache = cache?.servers?.[serverName];
|
||||
|
||||
const globalDirect = config.settings?.directTools;
|
||||
let toolFilter: true | string[] | false = false;
|
||||
if (definition.directTools !== undefined) {
|
||||
toolFilter = definition.directTools;
|
||||
} else if (globalDirect) {
|
||||
toolFilter = globalDirect;
|
||||
}
|
||||
|
||||
const tools: ToolState[] = [];
|
||||
if (serverCache && !this.authOnly) {
|
||||
for (const tool of serverCache.tools ?? []) {
|
||||
if (isToolExcluded(tool.name, serverName, this.prefix, definition.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isDirect = toolFilter === true || (Array.isArray(toolFilter) && toolFilter.includes(tool.name));
|
||||
tools.push({
|
||||
name: tool.name,
|
||||
description: tool.description ?? "",
|
||||
isDirect,
|
||||
wasDirect: isDirect,
|
||||
estimatedTokens: estimateTokens(tool),
|
||||
});
|
||||
}
|
||||
if (definition.exposeResources !== false) {
|
||||
for (const resource of serverCache.resources ?? []) {
|
||||
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
||||
if (isToolExcluded(baseName, serverName, this.prefix, definition.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isDirect = toolFilter === true || (Array.isArray(toolFilter) && toolFilter.includes(baseName));
|
||||
const ct: CachedTool = { name: baseName, description: resource.description };
|
||||
tools.push({
|
||||
name: baseName,
|
||||
description: resource.description ?? `Read resource: ${resource.uri}`,
|
||||
isDirect,
|
||||
wasDirect: isDirect,
|
||||
estimatedTokens: estimateTokens(ct),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const status = callbacks.getConnectionStatus(serverName);
|
||||
|
||||
this.servers.push({
|
||||
name: serverName,
|
||||
expanded: false,
|
||||
source: prov?.kind ?? "user",
|
||||
importKind: prov?.importKind,
|
||||
excludeTools: definition.excludeTools,
|
||||
exposeResources: definition.exposeResources !== false,
|
||||
connectionStatus: status,
|
||||
tools,
|
||||
hasCachedData: !!serverCache,
|
||||
});
|
||||
}
|
||||
|
||||
this.rebuildVisibleItems();
|
||||
this.resetInactivityTimeout();
|
||||
}
|
||||
|
||||
private resetInactivityTimeout(): void {
|
||||
if (this.inactivityTimeout) clearTimeout(this.inactivityTimeout);
|
||||
this.inactivityTimeout = setTimeout(() => {
|
||||
this.cleanup();
|
||||
this.done({ cancelled: true, changes: new Map() });
|
||||
}, McpPanel.INACTIVITY_MS);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.inactivityTimeout) {
|
||||
clearTimeout(this.inactivityTimeout);
|
||||
this.inactivityTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildVisibleItems(): void {
|
||||
const query = this.descSearchActive ? this.descQuery : this.nameQuery;
|
||||
const mode = this.descSearchActive ? "desc" : "name";
|
||||
|
||||
this.visibleItems = [];
|
||||
for (let si = 0; si < this.servers.length; si++) {
|
||||
const server = this.servers[si];
|
||||
if (query && this.authOnly) {
|
||||
const score = mode === "name" ? fuzzyScore(query, server.name) : 0;
|
||||
if (score > 0) {
|
||||
this.visibleItems.push({ type: "server", serverIndex: si });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
this.visibleItems.push({ type: "server", serverIndex: si });
|
||||
if (server.expanded || query) {
|
||||
for (let ti = 0; ti < server.tools.length; ti++) {
|
||||
const tool = server.tools[ti];
|
||||
if (query) {
|
||||
const score = mode === "name"
|
||||
? Math.max(
|
||||
fuzzyScore(query, tool.name),
|
||||
fuzzyScore(query, server.name) * 0.6,
|
||||
)
|
||||
: fuzzyScore(query, tool.description);
|
||||
if (score === 0) continue;
|
||||
}
|
||||
this.visibleItems.push({ type: "tool", serverIndex: si, toolIndex: ti });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (query && !this.authOnly) {
|
||||
this.visibleItems = this.visibleItems.filter((item) => {
|
||||
if (item.type === "server") {
|
||||
return this.visibleItems.some(
|
||||
(other) => other.type === "tool" && other.serverIndex === item.serverIndex,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateDirty(): void {
|
||||
this.dirty = this.servers.some((s) => s.tools.some((t) => t.isDirect !== t.wasDirect));
|
||||
}
|
||||
|
||||
private buildResult(): McpPanelResult {
|
||||
const changes = new Map<string, true | string[] | false>();
|
||||
for (const server of this.servers) {
|
||||
const changed = server.tools.some((t) => t.isDirect !== t.wasDirect);
|
||||
if (!changed) continue;
|
||||
const directTools = server.tools.filter((t) => t.isDirect);
|
||||
if (directTools.length === server.tools.length && server.tools.length > 0) {
|
||||
changes.set(server.name, true);
|
||||
} else if (directTools.length === 0) {
|
||||
changes.set(server.name, false);
|
||||
} else {
|
||||
changes.set(server.name, directTools.map((t) => t.name));
|
||||
}
|
||||
}
|
||||
return { changes, cancelled: false };
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.resetInactivityTimeout();
|
||||
this.importNotice = null;
|
||||
if (!this.authInFlight) this.authNotice = null;
|
||||
|
||||
if (this.confirmingDiscard) {
|
||||
this.handleDiscardInput(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Global shortcuts — always work, even during desc search
|
||||
if (matchesKey(data, "ctrl+c")) {
|
||||
this.cleanup();
|
||||
this.done({ cancelled: true, changes: new Map() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, "ctrl+s")) {
|
||||
this.cleanup();
|
||||
this.done(this.buildResult());
|
||||
return;
|
||||
}
|
||||
|
||||
// Modal description search mode
|
||||
if (this.descSearchActive) {
|
||||
if (matchesKey(data, "escape") || this.keys.selectConfirm(data)) {
|
||||
this.descSearchActive = false;
|
||||
this.descQuery = "";
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, "backspace")) {
|
||||
if (this.descQuery.length > 0) {
|
||||
this.descQuery = this.descQuery.slice(0, -1);
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectUp(data)) { this.moveCursor(-1); return; }
|
||||
if (this.keys.selectDown(data)) { this.moveCursor(1); return; }
|
||||
if (matchesKey(data, "space")) {
|
||||
// Toggle even while in desc search
|
||||
const item = this.visibleItems[this.cursorIndex];
|
||||
if (item) this.toggleItem(item);
|
||||
return;
|
||||
}
|
||||
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
this.descQuery += data;
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, "escape")) {
|
||||
if (this.nameQuery) {
|
||||
this.nameQuery = "";
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
return;
|
||||
}
|
||||
if (this.dirty) {
|
||||
this.confirmingDiscard = true;
|
||||
this.discardSelected = 1;
|
||||
return;
|
||||
}
|
||||
this.cleanup();
|
||||
this.done({ cancelled: true, changes: new Map() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keys.selectUp(data)) { this.moveCursor(-1); return; }
|
||||
if (this.keys.selectDown(data)) { this.moveCursor(1); return; }
|
||||
|
||||
if (matchesKey(data, "space")) {
|
||||
const item = this.visibleItems[this.cursorIndex];
|
||||
if (item && !this.authOnly) this.toggleItem(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keys.selectConfirm(data)) {
|
||||
const item = this.visibleItems[this.cursorIndex];
|
||||
if (!item) return;
|
||||
const server = this.servers[item.serverIndex];
|
||||
if (item.type === "server") {
|
||||
if (this.authOnly || server.connectionStatus === "needs-auth") {
|
||||
this.authenticateServer(server);
|
||||
return;
|
||||
}
|
||||
server.expanded = !server.expanded;
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
} else if (item.toolIndex !== undefined) {
|
||||
const tool = server.tools[item.toolIndex];
|
||||
tool.isDirect = !tool.isDirect;
|
||||
if (tool.isDirect && server.source === "import") {
|
||||
this.importNotice = `Imported from ${server.importKind ?? "external"} — will copy to user config on save`;
|
||||
}
|
||||
this.updateDirty();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, "ctrl+a")) {
|
||||
const item = this.visibleItems[this.cursorIndex];
|
||||
if (item) this.authenticateSelectedServer(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, "ctrl+r")) {
|
||||
const item = this.visibleItems[this.cursorIndex];
|
||||
if (!item) return;
|
||||
const server = this.servers[item.serverIndex];
|
||||
if (server.connectionStatus === "connecting") return;
|
||||
server.connectionStatus = "connecting";
|
||||
this.callbacks.reconnect(server.name).then(() => {
|
||||
server.connectionStatus = this.callbacks.getConnectionStatus(server.name);
|
||||
if (server.connectionStatus === "connected") {
|
||||
const entry = this.callbacks.refreshCacheAfterReconnect(server.name);
|
||||
if (entry) {
|
||||
this.rebuildServerTools(server, entry);
|
||||
}
|
||||
server.hasCachedData = true;
|
||||
}
|
||||
this.tui.requestRender();
|
||||
}).catch((error) => {
|
||||
server.connectionStatus = "failed";
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.authNotice = `Reconnect failed for ${server.name}: ${message}`;
|
||||
this.tui.requestRender();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === "?") {
|
||||
if (this.authOnly) return;
|
||||
this.descSearchActive = true;
|
||||
this.descQuery = "";
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace removes from name query
|
||||
if (matchesKey(data, "backspace")) {
|
||||
if (this.nameQuery.length > 0) {
|
||||
this.nameQuery = this.nameQuery.slice(0, -1);
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// All other printable chars → always-on name search
|
||||
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
this.nameQuery += data;
|
||||
this.rebuildVisibleItems();
|
||||
this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private authenticateSelectedServer(item: VisibleItem): void {
|
||||
this.authenticateServer(this.servers[item.serverIndex]);
|
||||
}
|
||||
|
||||
private authenticateServer(server: ServerState): void {
|
||||
if (this.authInFlight) return;
|
||||
if (!this.callbacks.canAuthenticate(server.name)) {
|
||||
this.authNotice = `${server.name} does not use OAuth authentication.`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.authInFlight = server.name;
|
||||
this.authNotice = `Authenticating ${server.name}...`;
|
||||
this.tui.requestRender();
|
||||
|
||||
this.callbacks.authenticate(server.name).then((result) => {
|
||||
server.connectionStatus = this.callbacks.getConnectionStatus(server.name);
|
||||
this.authNotice = result.ok
|
||||
? `OAuth finished for ${server.name}. Run reconnect if it is still idle.`
|
||||
: `OAuth failed for ${server.name}${result.message ? `: ${result.message}` : ". Check the notification for details."}`;
|
||||
this.authInFlight = null;
|
||||
this.tui.requestRender();
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
server.connectionStatus = this.callbacks.getConnectionStatus(server.name);
|
||||
this.authNotice = `OAuth failed for ${server.name}: ${message}`;
|
||||
this.authInFlight = null;
|
||||
this.tui.requestRender();
|
||||
});
|
||||
}
|
||||
|
||||
private toggleItem(item: VisibleItem): void {
|
||||
if (this.authOnly) return;
|
||||
const server = this.servers[item.serverIndex];
|
||||
if (item.type === "server") {
|
||||
const newState = !server.tools.every((t) => t.isDirect);
|
||||
if (server.source === "import" && newState) {
|
||||
this.importNotice = `Imported from ${server.importKind ?? "external"} — will copy to user config on save`;
|
||||
}
|
||||
for (const t of server.tools) t.isDirect = newState;
|
||||
} else if (item.toolIndex !== undefined) {
|
||||
const tool = server.tools[item.toolIndex];
|
||||
tool.isDirect = !tool.isDirect;
|
||||
if (tool.isDirect && server.source === "import") {
|
||||
this.importNotice = `Imported from ${server.importKind ?? "external"} — will copy to user config on save`;
|
||||
}
|
||||
}
|
||||
this.updateDirty();
|
||||
}
|
||||
|
||||
private handleDiscardInput(data: string): void {
|
||||
if (matchesKey(data, "ctrl+c")) {
|
||||
this.cleanup();
|
||||
this.done({ cancelled: true, changes: new Map() });
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, "escape") || data === "n" || data === "N") {
|
||||
this.confirmingDiscard = false;
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectConfirm(data)) {
|
||||
if (this.discardSelected === 0) {
|
||||
this.cleanup();
|
||||
this.done({ cancelled: true, changes: new Map() });
|
||||
} else {
|
||||
this.confirmingDiscard = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data === "y" || data === "Y") {
|
||||
this.cleanup();
|
||||
this.done({ cancelled: true, changes: new Map() });
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, "left") || matchesKey(data, "right") || matchesKey(data, "tab")) {
|
||||
this.discardSelected = this.discardSelected === 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
private moveCursor(delta: number): void {
|
||||
if (this.visibleItems.length === 0) return;
|
||||
this.cursorIndex = Math.max(0, Math.min(this.visibleItems.length - 1, this.cursorIndex + delta));
|
||||
}
|
||||
|
||||
private rebuildServerTools(server: ServerState, entry: ServerCacheEntry): void {
|
||||
const existingState = new Map<string, boolean>();
|
||||
for (const t of server.tools) existingState.set(t.name, t.isDirect);
|
||||
|
||||
const newTools: ToolState[] = [];
|
||||
for (const tool of entry.tools ?? []) {
|
||||
if (isToolExcluded(tool.name, server.name, this.prefix, server.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prev = existingState.get(tool.name);
|
||||
const isDirect = prev !== undefined ? prev : false;
|
||||
newTools.push({
|
||||
name: tool.name,
|
||||
description: tool.description ?? "",
|
||||
isDirect,
|
||||
wasDirect: prev !== undefined ? server.tools.find((t) => t.name === tool.name)?.wasDirect ?? false : false,
|
||||
estimatedTokens: estimateTokens(tool),
|
||||
});
|
||||
}
|
||||
|
||||
if (server.exposeResources) {
|
||||
for (const resource of entry.resources ?? []) {
|
||||
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
||||
if (isToolExcluded(baseName, server.name, this.prefix, server.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prev = existingState.get(baseName);
|
||||
const isDirect = prev !== undefined ? prev : false;
|
||||
const ct: CachedTool = { name: baseName, description: resource.description };
|
||||
newTools.push({
|
||||
name: baseName,
|
||||
description: resource.description ?? `Read resource: ${resource.uri}`,
|
||||
isDirect,
|
||||
wasDirect: prev !== undefined ? server.tools.find((t) => t.name === baseName)?.wasDirect ?? false : false,
|
||||
estimatedTokens: estimateTokens(ct),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
server.tools = newTools;
|
||||
this.rebuildVisibleItems();
|
||||
this.updateDirty();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const innerW = width - 2;
|
||||
const lines: string[] = [];
|
||||
const t = this.t;
|
||||
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
||||
const italic = (s: string) => `\x1b[3m${s}\x1b[23m`;
|
||||
const inverse = (s: string) => `\x1b[7m${s}\x1b[27m`;
|
||||
|
||||
const row = (content: string) =>
|
||||
fg(t.border, "│") + truncateToWidth(" " + content, innerW, "…", true) + fg(t.border, "│");
|
||||
const emptyRow = () => fg(t.border, "│") + " ".repeat(innerW) + fg(t.border, "│");
|
||||
const divider = () => fg(t.border, "├" + "─".repeat(innerW) + "┤");
|
||||
|
||||
const titleText = this.authOnly ? " MCP OAuth " : " MCP Servers ";
|
||||
const borderLen = innerW - visibleWidth(titleText);
|
||||
const leftB = Math.floor(borderLen / 2);
|
||||
const rightB = borderLen - leftB;
|
||||
lines.push(fg(t.border, "╭" + "─".repeat(leftB)) + fg(t.title, titleText) + fg(t.border, "─".repeat(rightB) + "╮"));
|
||||
|
||||
lines.push(emptyRow());
|
||||
|
||||
const cursor = fg(t.selected, "│");
|
||||
const searchIcon = fg(t.border, "◎");
|
||||
if (this.descSearchActive) {
|
||||
lines.push(row(`${searchIcon} ${fg(t.needsAuth, "desc:")} ${this.descQuery}${cursor}`));
|
||||
} else if (this.nameQuery) {
|
||||
lines.push(row(`${searchIcon} ${this.nameQuery}${cursor}`));
|
||||
} else {
|
||||
lines.push(row(`${searchIcon} ${fg(t.placeholder, italic("search..."))}`));
|
||||
}
|
||||
|
||||
lines.push(emptyRow());
|
||||
if (this.noticeLines.length > 0) {
|
||||
for (const notice of this.noticeLines) {
|
||||
lines.push(row(fg(t.hint, italic(notice))));
|
||||
}
|
||||
lines.push(emptyRow());
|
||||
}
|
||||
lines.push(divider());
|
||||
|
||||
if (this.servers.length === 0) {
|
||||
lines.push(emptyRow());
|
||||
lines.push(row(fg(t.hint, italic(this.authOnly ? "No OAuth-capable MCP servers configured." : "No MCP servers configured."))));
|
||||
lines.push(emptyRow());
|
||||
} else {
|
||||
const maxVis = McpPanel.MAX_VISIBLE;
|
||||
const total = this.visibleItems.length;
|
||||
const startIdx = Math.max(0, Math.min(this.cursorIndex - Math.floor(maxVis / 2), total - maxVis));
|
||||
const endIdx = Math.min(startIdx + maxVis, total);
|
||||
|
||||
lines.push(emptyRow());
|
||||
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const item = this.visibleItems[i];
|
||||
const isCursor = i === this.cursorIndex;
|
||||
const server = this.servers[item.serverIndex];
|
||||
|
||||
if (item.type === "server") {
|
||||
lines.push(row(this.renderServerRow(server, isCursor)));
|
||||
} else if (item.toolIndex !== undefined) {
|
||||
lines.push(row(this.renderToolRow(server.tools[item.toolIndex], isCursor, innerW)));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(emptyRow());
|
||||
|
||||
if (total > maxVis) {
|
||||
const prog = Math.round(((this.cursorIndex + 1) / total) * 10);
|
||||
lines.push(row(`${rainbowProgress(prog, 10)} ${fg(t.hint, `${this.cursorIndex + 1}/${total}`)}`));
|
||||
lines.push(emptyRow());
|
||||
}
|
||||
|
||||
if (this.importNotice) {
|
||||
lines.push(row(fg(t.needsAuth, italic(this.importNotice))));
|
||||
lines.push(emptyRow());
|
||||
}
|
||||
if (this.authNotice) {
|
||||
lines.push(row(fg(t.needsAuth, italic(this.authNotice))));
|
||||
lines.push(emptyRow());
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(divider());
|
||||
lines.push(emptyRow());
|
||||
|
||||
if (this.confirmingDiscard) {
|
||||
const discardBtn = this.discardSelected === 0
|
||||
? inverse(bold(fg(t.cancel, " Discard ")))
|
||||
: fg(t.hint, " Discard ");
|
||||
const keepBtn = this.discardSelected === 1
|
||||
? inverse(bold(fg(t.confirm, " Keep ")))
|
||||
: fg(t.hint, " Keep ");
|
||||
lines.push(row(`Discard unsaved changes? ${discardBtn} ${keepBtn}`));
|
||||
} else {
|
||||
if (this.authOnly) {
|
||||
lines.push(row(fg(t.description, "select a server to authenticate")));
|
||||
} else {
|
||||
const directCount = this.servers.reduce((sum, s) => sum + s.tools.filter((t) => t.isDirect).length, 0);
|
||||
const totalTokens = this.servers.reduce(
|
||||
(sum, s) => sum + s.tools.filter((t) => t.isDirect).reduce((ts, t) => ts + t.estimatedTokens, 0),
|
||||
0,
|
||||
);
|
||||
const stats =
|
||||
directCount > 0 ? `${directCount} direct ~${totalTokens.toLocaleString()} tokens` : "no direct tools";
|
||||
lines.push(row(fg(t.description, stats + (this.dirty ? fg(t.needsAuth, " (unsaved)") : ""))));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(emptyRow());
|
||||
const hints = this.authOnly
|
||||
? [
|
||||
italic("↑↓") + " navigate",
|
||||
italic("⏎") + " auth",
|
||||
italic("ctrl+a") + " auth",
|
||||
italic("esc") + " clear/close",
|
||||
italic("ctrl+c") + " quit",
|
||||
]
|
||||
: [
|
||||
italic("↑↓") + " navigate",
|
||||
italic("space") + " toggle",
|
||||
italic("⏎") + " expand/auth",
|
||||
italic("ctrl+a") + " auth",
|
||||
italic("ctrl+r") + " reconnect",
|
||||
italic("?") + " desc search",
|
||||
italic("ctrl+s") + " save",
|
||||
italic("esc") + " clear/close",
|
||||
italic("ctrl+c") + " quit",
|
||||
];
|
||||
const gap = " ";
|
||||
const gapW = 2;
|
||||
const maxW = innerW - 2;
|
||||
let curLine = "";
|
||||
let curW = 0;
|
||||
for (const hint of hints) {
|
||||
const hw = visibleWidth(hint);
|
||||
const needed = curW === 0 ? hw : gapW + hw;
|
||||
if (curW > 0 && curW + needed > maxW) {
|
||||
lines.push(row(fg(t.hint, curLine)));
|
||||
curLine = hint;
|
||||
curW = hw;
|
||||
} else {
|
||||
curLine += (curW > 0 ? gap : "") + hint;
|
||||
curW += needed;
|
||||
}
|
||||
}
|
||||
if (curLine) lines.push(row(fg(t.hint, curLine)));
|
||||
|
||||
lines.push(fg(t.border, "╰" + "─".repeat(innerW) + "╯"));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderServerRow(server: ServerState, isCursor: boolean): string {
|
||||
const t = this.t;
|
||||
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
||||
|
||||
const expandIcon = server.expanded ? "▾" : "▸";
|
||||
const prefix = isCursor ? fg(t.selected, expandIcon) : fg(t.border, server.expanded ? expandIcon : "·");
|
||||
|
||||
const nameStr = isCursor ? bold(fg(t.selected, server.name)) : server.name;
|
||||
const importLabel = server.source === "import" ? fg(t.description, ` (${server.importKind ?? "import"})`) : "";
|
||||
const statusLabel = this.renderConnectionStatus(server);
|
||||
|
||||
if (!server.hasCachedData && !this.authOnly) {
|
||||
return `${prefix} ${nameStr}${importLabel} ${fg(t.description, "(not cached)")}${statusLabel}`;
|
||||
}
|
||||
|
||||
const directCount = server.tools.filter((t) => t.isDirect).length;
|
||||
const totalCount = server.tools.length;
|
||||
let toggleIcon = fg(t.description, "○");
|
||||
if (directCount === totalCount && totalCount > 0) {
|
||||
toggleIcon = fg(t.direct, "●");
|
||||
} else if (directCount > 0) {
|
||||
toggleIcon = fg(t.needsAuth, "◐");
|
||||
}
|
||||
|
||||
let toolInfo = "";
|
||||
if (totalCount > 0) {
|
||||
toolInfo = `${directCount}/${totalCount}`;
|
||||
if (directCount > 0) {
|
||||
const tokens = server.tools.filter((t) => t.isDirect).reduce((s, t) => s + t.estimatedTokens, 0);
|
||||
toolInfo += ` ~${tokens.toLocaleString()}`;
|
||||
}
|
||||
toolInfo = fg(t.description, toolInfo);
|
||||
}
|
||||
|
||||
return `${prefix} ${toggleIcon} ${nameStr}${importLabel} ${toolInfo}${statusLabel}`;
|
||||
}
|
||||
|
||||
private renderConnectionStatus(server: ServerState): string {
|
||||
const t = this.t;
|
||||
if (this.authInFlight === server.name) return ` ${fg(t.needsAuth, "authenticating")}`;
|
||||
if (server.connectionStatus === "needs-auth") return ` ${fg(t.needsAuth, "needs auth")}`;
|
||||
if (server.connectionStatus === "connecting") return ` ${fg(t.needsAuth, "connecting")}`;
|
||||
if (server.connectionStatus === "failed") return ` ${fg(t.cancel, "failed")}`;
|
||||
if (this.authOnly && server.connectionStatus === "connected") return ` ${fg(t.direct, "connected")}`;
|
||||
if (this.authOnly) return ` ${fg(t.description, "idle")}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
private renderToolRow(tool: ToolState, isCursor: boolean, innerW: number): string {
|
||||
const t = this.t;
|
||||
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
||||
|
||||
const toggleIcon = tool.isDirect ? fg(t.direct, "●") : fg(t.description, "○");
|
||||
const cursor = isCursor ? fg(t.selected, "▸") : " ";
|
||||
const nameStr = isCursor ? bold(fg(t.selected, tool.name)) : tool.name;
|
||||
|
||||
const prefixLen = 7 + visibleWidth(tool.name);
|
||||
const maxDescLen = Math.max(0, innerW - prefixLen - 8);
|
||||
const descStr =
|
||||
maxDescLen > 5 && tool.description
|
||||
? fg(t.description, "— " + truncateToWidth(tool.description, maxDescLen, "…"))
|
||||
: "";
|
||||
|
||||
return ` ${cursor} ${toggleIcon} ${nameStr} ${descStr}`;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
dispose(): void {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export function createMcpPanel(
|
||||
config: McpConfig,
|
||||
cache: MetadataCache | null,
|
||||
provenance: Map<string, ServerProvenance>,
|
||||
callbacks: McpPanelCallbacks,
|
||||
tui: { requestRender(): void },
|
||||
done: (result: McpPanelResult) => void,
|
||||
options?: { noticeLines?: string[]; authOnly?: boolean; keybindings?: PanelKeybindings },
|
||||
): McpPanel & { dispose(): void } {
|
||||
return new McpPanel(config, cache, provenance, callbacks, tui, done, options ?? {});
|
||||
}
|
||||
581
agent/extensions/pi-mcp-adapter/mcp-setup-panel.ts
Normal file
@@ -0,0 +1,581 @@
|
||||
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
||||
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
|
||||
import type { ImportKind } from "./types.ts";
|
||||
import type { ConfigWritePreview, McpDiscoverySummary } from "./config.ts";
|
||||
import type { McpOnboardingState } from "./onboarding-state.ts";
|
||||
|
||||
interface SetupTheme {
|
||||
border: string;
|
||||
title: string;
|
||||
selected: string;
|
||||
hint: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
muted: string;
|
||||
}
|
||||
|
||||
const DEFAULT_THEME: SetupTheme = {
|
||||
border: "2",
|
||||
title: "36",
|
||||
selected: "32",
|
||||
hint: "2",
|
||||
success: "32",
|
||||
warning: "33",
|
||||
muted: "2;3",
|
||||
};
|
||||
|
||||
function fg(code: string, text: string): string {
|
||||
return code ? `\x1b[${code}m${text}\x1b[0m` : text;
|
||||
}
|
||||
|
||||
function wrapText(text: string, width: number): string[] {
|
||||
if (width <= 8) return [text];
|
||||
const words = text.split(/\s+/).filter(Boolean);
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
for (const word of words) {
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
if (visibleWidth(candidate) <= width) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
current = word;
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
return lines.length > 0 ? lines : [""];
|
||||
}
|
||||
|
||||
export interface SetupPanelCallbacks {
|
||||
previewImports: (imports: ImportKind[]) => ConfigWritePreview;
|
||||
previewStarterProject: () => ConfigWritePreview;
|
||||
previewRepoPrompt: () => ConfigWritePreview | null;
|
||||
adoptImports: (imports: ImportKind[]) => Promise<{ added: ImportKind[]; path: string }>;
|
||||
scaffoldProjectConfig: () => Promise<{ path: string }>;
|
||||
addRepoPrompt: () => Promise<{ path: string; serverName: string }>;
|
||||
openPath: (path: string) => Promise<void>;
|
||||
markSetupCompleted: () => void;
|
||||
}
|
||||
|
||||
export interface SetupPanelOptions {
|
||||
mode: "empty" | "setup";
|
||||
onboardingState: McpOnboardingState;
|
||||
keybindings?: PanelKeybindings;
|
||||
}
|
||||
|
||||
type Screen = "empty" | "setup" | "imports" | "paths";
|
||||
|
||||
type ActionId =
|
||||
| "run-setup"
|
||||
| "adopt-imports"
|
||||
| "view-example"
|
||||
| "show-precedence"
|
||||
| "open-paths"
|
||||
| "add-repoprompt"
|
||||
| "scaffold-project"
|
||||
| "close";
|
||||
|
||||
interface Action {
|
||||
id: ActionId;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class McpSetupPanel {
|
||||
private screen: Screen;
|
||||
private actionCursor = 0;
|
||||
private importCursor = 0;
|
||||
private pathCursor = 0;
|
||||
private selectedImports = new Set<ImportKind>();
|
||||
private busy = false;
|
||||
private notice: { text: string; tone: "success" | "warning" | "muted" } | null = null;
|
||||
private tui: { requestRender(): void };
|
||||
private t = DEFAULT_THEME;
|
||||
private keys: PanelKeys;
|
||||
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private static readonly INACTIVITY_MS = 60_000;
|
||||
|
||||
constructor(
|
||||
private discovery: McpDiscoverySummary,
|
||||
private callbacks: SetupPanelCallbacks,
|
||||
private options: SetupPanelOptions,
|
||||
tui: { requestRender(): void },
|
||||
private done: () => void,
|
||||
) {
|
||||
this.tui = tui;
|
||||
this.keys = createPanelKeys(options.keybindings);
|
||||
this.screen = options.mode;
|
||||
for (const entry of discovery.imports) {
|
||||
this.selectedImports.add(entry.kind);
|
||||
}
|
||||
this.resetInactivityTimeout();
|
||||
}
|
||||
|
||||
private resetInactivityTimeout(): void {
|
||||
if (this.inactivityTimeout) clearTimeout(this.inactivityTimeout);
|
||||
this.inactivityTimeout = setTimeout(() => {
|
||||
this.cleanup();
|
||||
this.done();
|
||||
}, McpSetupPanel.INACTIVITY_MS);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.inactivityTimeout) {
|
||||
clearTimeout(this.inactivityTimeout);
|
||||
this.inactivityTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
private getActions(): Action[] {
|
||||
const actions: Action[] = [];
|
||||
if (this.screen === "empty") {
|
||||
actions.push({ id: "run-setup", label: "Run setup", description: "Inspect detected configs, adopt imports, and scaffold a minimal `.mcp.json`." });
|
||||
}
|
||||
if (this.discovery.imports.length > 0) {
|
||||
actions.push({ id: "adopt-imports", label: "Adopt detected compatibility imports", description: `Choose which host-specific MCP configs Pi should import into its own override file. ${this.discovery.imports.length} source${this.discovery.imports.length === 1 ? "" : "s"} found.` });
|
||||
}
|
||||
actions.push({ id: "view-example", label: "View example `.mcp.json`", description: "Preview a working shared MCP config you can paste or adapt." });
|
||||
if (!this.discovery.sources.some((source) => source.id === "shared-project" && source.exists)) {
|
||||
actions.push({ id: "scaffold-project", label: "Scaffold project `.mcp.json`", description: "Write a minimal project config using the standard shared MCP file path, then reload Pi." });
|
||||
}
|
||||
actions.push({ id: "show-precedence", label: "Explain config precedence", description: "Show the read order and where Pi writes compatibility settings." });
|
||||
if (this.getDetectedPaths().length > 0) {
|
||||
actions.push({ id: "open-paths", label: "Open detected config paths", description: "Browse the actual config files that Pi discovered on this machine." });
|
||||
}
|
||||
if (!this.discovery.repoPrompt.configured && this.discovery.repoPrompt.executablePath && this.discovery.repoPrompt.targetPath && this.discovery.repoPrompt.entry && this.discovery.repoPrompt.serverName) {
|
||||
actions.push({ id: "add-repoprompt", label: "Add RepoPrompt to shared MCP config", description: "Write a standard MCP entry for RepoPrompt to the recommended shared target, then reload MCP in-session." });
|
||||
}
|
||||
actions.push({ id: "close", label: "Close", description: "Exit the onboarding flow." });
|
||||
return actions;
|
||||
}
|
||||
|
||||
private getDetectedPaths(): string[] {
|
||||
const paths = [
|
||||
...this.discovery.sources.filter((source) => source.exists).map((source) => source.path),
|
||||
...this.discovery.imports.map((entry) => entry.path),
|
||||
];
|
||||
return [...new Set(paths)];
|
||||
}
|
||||
|
||||
private getSelectedAction(): Action | null {
|
||||
const actions = this.getActions();
|
||||
return actions[this.actionCursor] ?? null;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.resetInactivityTimeout();
|
||||
if (!this.busy) this.notice = null;
|
||||
|
||||
if (matchesKey(data, "ctrl+c")) {
|
||||
this.cleanup();
|
||||
this.done();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, "escape")) {
|
||||
if (this.screen === "imports" || this.screen === "paths") {
|
||||
this.screen = this.discovery.hasAnyConfig ? "setup" : "empty";
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
this.cleanup();
|
||||
this.done();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.busy) return;
|
||||
|
||||
if (this.screen === "imports") {
|
||||
this.handleImportsInput(data);
|
||||
return;
|
||||
}
|
||||
if (this.screen === "paths") {
|
||||
this.handlePathsInput(data);
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = this.getActions();
|
||||
if (this.keys.selectUp(data)) {
|
||||
this.actionCursor = Math.max(0, this.actionCursor - 1);
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectDown(data)) {
|
||||
this.actionCursor = Math.min(actions.length - 1, this.actionCursor + 1);
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectConfirm(data)) {
|
||||
const selected = this.getSelectedAction();
|
||||
if (selected) void this.runAction(selected.id);
|
||||
}
|
||||
}
|
||||
|
||||
private handleImportsInput(data: string): void {
|
||||
const imports = this.discovery.imports;
|
||||
if (this.keys.selectUp(data)) {
|
||||
this.importCursor = Math.max(0, this.importCursor - 1);
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectDown(data)) {
|
||||
this.importCursor = Math.min(imports.length - 1, this.importCursor + 1);
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, "space")) {
|
||||
const current = imports[this.importCursor];
|
||||
if (!current) return;
|
||||
if (this.selectedImports.has(current.kind)) {
|
||||
this.selectedImports.delete(current.kind);
|
||||
} else {
|
||||
this.selectedImports.add(current.kind);
|
||||
}
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectConfirm(data)) {
|
||||
void this.applySelectedImports();
|
||||
}
|
||||
}
|
||||
|
||||
private handlePathsInput(data: string): void {
|
||||
const paths = this.getDetectedPaths();
|
||||
if (this.keys.selectUp(data)) {
|
||||
this.pathCursor = Math.max(0, this.pathCursor - 1);
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectDown(data)) {
|
||||
this.pathCursor = Math.min(paths.length - 1, this.pathCursor + 1);
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (this.keys.selectConfirm(data)) {
|
||||
const selected = paths[this.pathCursor];
|
||||
if (!selected) return;
|
||||
void this.runBusy(async () => {
|
||||
await this.callbacks.openPath(selected);
|
||||
this.notice = { text: `Opened ${selected}`, tone: "success" };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async runAction(action: ActionId): Promise<void> {
|
||||
if (action === "run-setup") {
|
||||
this.screen = "setup";
|
||||
this.actionCursor = 0;
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (action === "adopt-imports") {
|
||||
this.screen = "imports";
|
||||
this.importCursor = 0;
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (action === "open-paths") {
|
||||
this.screen = "paths";
|
||||
this.pathCursor = 0;
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (action === "scaffold-project") {
|
||||
await this.runBusy(async () => {
|
||||
const result = await this.callbacks.scaffoldProjectConfig();
|
||||
this.callbacks.markSetupCompleted();
|
||||
this.notice = { text: `Wrote starter config to ${result.path}. Pi will reload after this panel closes.`, tone: "success" };
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === "add-repoprompt") {
|
||||
await this.runBusy(async () => {
|
||||
const result = await this.callbacks.addRepoPrompt();
|
||||
this.callbacks.markSetupCompleted();
|
||||
this.notice = { text: `Added ${result.serverName} to ${result.path}. Pi will reload after this panel closes.`, tone: "success" };
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === "close") {
|
||||
this.cleanup();
|
||||
this.done();
|
||||
return;
|
||||
}
|
||||
|
||||
this.notice = { text: "Review the details below. Press Enter on an action with a side effect to apply it.", tone: "muted" };
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
private async applySelectedImports(): Promise<void> {
|
||||
const selected = this.discovery.imports.filter((entry) => this.selectedImports.has(entry.kind)).map((entry) => entry.kind);
|
||||
if (selected.length === 0) {
|
||||
this.notice = { text: "Select at least one compatibility import first.", tone: "warning" };
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runBusy(async () => {
|
||||
const result = await this.callbacks.adoptImports(selected);
|
||||
this.callbacks.markSetupCompleted();
|
||||
this.notice = result.added.length > 0
|
||||
? { text: `Added ${result.added.join(", ")} to ${result.path}. Pi will reload after this panel closes.`, tone: "success" }
|
||||
: { text: `No changes needed in ${result.path}.`, tone: "muted" };
|
||||
this.screen = this.discovery.hasAnyConfig ? "setup" : "empty";
|
||||
this.actionCursor = 0;
|
||||
});
|
||||
}
|
||||
|
||||
private async runBusy(fn: () => Promise<void>): Promise<void> {
|
||||
this.busy = true;
|
||||
this.notice = { text: "Working...", tone: "muted" };
|
||||
this.tui.requestRender();
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
this.notice = {
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
tone: "warning",
|
||||
};
|
||||
} finally {
|
||||
this.busy = false;
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const innerW = Math.max(40, width - 2);
|
||||
const lines: string[] = [];
|
||||
const border = fg(this.t.border, "─".repeat(innerW));
|
||||
lines.push(`┌${border}┐`);
|
||||
lines.push(this.padLine(fg(this.t.title, "MCP setup"), innerW));
|
||||
lines.push(this.padLine(this.discoverySummaryLine(), innerW));
|
||||
lines.push(this.padLine(fg(this.t.muted, this.secondarySummaryLine()), innerW));
|
||||
lines.push(this.padLine("", innerW));
|
||||
|
||||
if (this.notice) {
|
||||
const tone = this.notice.tone === "success" ? this.t.success : this.notice.tone === "warning" ? this.t.warning : this.t.hint;
|
||||
for (const line of wrapText(this.notice.text, innerW - 6)) {
|
||||
lines.push(this.padLine(fg(tone, line), innerW));
|
||||
}
|
||||
lines.push(this.padLine("", innerW));
|
||||
}
|
||||
|
||||
lines.push(`├${border}┤`);
|
||||
|
||||
if (this.screen === "imports") {
|
||||
lines.push(...this.renderImports(innerW));
|
||||
} else if (this.screen === "paths") {
|
||||
lines.push(...this.renderPaths(innerW));
|
||||
} else {
|
||||
lines.push(...this.renderActions(innerW));
|
||||
}
|
||||
|
||||
lines.push(`└${border}┘`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderActions(innerW: number): string[] {
|
||||
const lines: string[] = [];
|
||||
const actions = this.getActions();
|
||||
for (let index = 0; index < actions.length; index++) {
|
||||
const action = actions[index];
|
||||
const selected = index === this.actionCursor;
|
||||
const cursor = selected ? fg(this.t.selected, "›") : " ";
|
||||
lines.push(this.padLine(`${cursor} ${truncateToWidth(action.label, innerW - 4)}`, innerW));
|
||||
}
|
||||
lines.push(this.padLine("", innerW));
|
||||
|
||||
const preview = this.getActionPreview(this.getSelectedAction()?.id ?? "view-example");
|
||||
for (const line of preview) {
|
||||
lines.push(this.padLine(line, innerW));
|
||||
}
|
||||
lines.push(this.padLine("", innerW));
|
||||
lines.push(this.padLine(fg(this.t.muted, "Enter selects, Esc goes back, Ctrl+C closes."), innerW));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderImports(innerW: number): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(this.padLine("Select compatibility imports. Space toggles, Enter saves, Esc goes back.", innerW));
|
||||
lines.push(this.padLine("", innerW));
|
||||
for (let index = 0; index < this.discovery.imports.length; index++) {
|
||||
const entry = this.discovery.imports[index];
|
||||
const selected = this.selectedImports.has(entry.kind) ? "[x]" : "[ ]";
|
||||
const cursor = index === this.importCursor ? fg(this.t.selected, "›") : " ";
|
||||
lines.push(this.padLine(`${cursor} ${selected} ${entry.kind} ${entry.path}`, innerW));
|
||||
}
|
||||
lines.push(this.padLine("", innerW));
|
||||
const selected = this.discovery.imports.filter((entry) => this.selectedImports.has(entry.kind)).map((entry) => entry.kind);
|
||||
const preview = this.callbacks.previewImports(selected);
|
||||
for (const line of this.formatWritePreview("Compatibility import write preview", preview)) {
|
||||
lines.push(this.padLine(line, innerW));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private renderPaths(innerW: number): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(this.padLine("Select a detected config path to open. Enter opens it, Esc goes back.", innerW));
|
||||
lines.push(this.padLine("", innerW));
|
||||
const paths = this.getDetectedPaths();
|
||||
for (let index = 0; index < paths.length; index++) {
|
||||
const cursor = index === this.pathCursor ? fg(this.t.selected, "›") : " ";
|
||||
lines.push(this.padLine(`${cursor} ${paths[index]}`, innerW));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private discoverySummaryLine(): string {
|
||||
if (!this.discovery.hasAnyConfig) {
|
||||
return fg(this.t.warning, this.options.onboardingState.setupCompleted
|
||||
? "No MCP servers are active right now."
|
||||
: "No MCP config is active yet.");
|
||||
}
|
||||
|
||||
if (this.discovery.totalServerCount === 0 && (this.discovery.imports.length > 0 || !!this.discovery.repoPrompt.executablePath)) {
|
||||
return fg(this.t.warning, "Pi found MCP-related setup options, but none are active in Pi yet.");
|
||||
}
|
||||
|
||||
const shared = this.discovery.sources.filter((source) => source.kind === "shared" && source.serverCount > 0).length;
|
||||
const piOwned = this.discovery.sources.filter((source) => source.kind === "pi" && source.serverCount > 0).length;
|
||||
return fg(this.t.hint, `Detected ${this.discovery.totalServerCount} configured servers across ${shared} shared and ${piOwned} Pi-owned source${shared + piOwned === 1 ? "" : "s"}.`);
|
||||
}
|
||||
|
||||
private secondarySummaryLine(): string {
|
||||
if (!this.discovery.hasAnyConfig) {
|
||||
return "Create a shared `.mcp.json`, adopt host imports, or quick-add RepoPrompt from this screen.";
|
||||
}
|
||||
if (this.discovery.totalServerCount === 0 && this.discovery.imports.length > 0) {
|
||||
return `Detected ${this.discovery.imports.length} compatibility import source${this.discovery.imports.length === 1 ? "" : "s"}. Adopt them into Pi or inspect the underlying files.`;
|
||||
}
|
||||
return "Shared MCP files are preferred. Pi-owned files are only for compatibility imports and adapter-specific overrides.";
|
||||
}
|
||||
|
||||
private getActionPreview(action: ActionId): string[] {
|
||||
switch (action) {
|
||||
case "run-setup":
|
||||
return this.formatPreview([
|
||||
"Run setup to adopt host-specific imports, inspect detected paths, and scaffold a minimal `.mcp.json` if needed.",
|
||||
]);
|
||||
case "adopt-imports":
|
||||
return this.formatWritePreview(
|
||||
"Compatibility import write preview",
|
||||
this.callbacks.previewImports(this.discovery.imports.filter((entry) => this.selectedImports.has(entry.kind)).map((entry) => entry.kind)),
|
||||
[
|
||||
`Detected imports: ${this.discovery.imports.map((entry) => `${entry.kind} (${entry.serverCount} servers)`).join(", ")}`,
|
||||
"Selected imports are written into the Pi agent dir config as Pi-owned compatibility state.",
|
||||
],
|
||||
);
|
||||
case "view-example":
|
||||
return this.formatPreview([
|
||||
"Example shared `.mcp.json`:",
|
||||
"{",
|
||||
' "mcpServers": {',
|
||||
' "chrome-devtools": {',
|
||||
' "command": "npx",',
|
||||
' "args": ["-y", "chrome-devtools-mcp@latest"]',
|
||||
" }",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
"Use Scaffold project `.mcp.json` when you want a safe empty shell instead of a live example server.",
|
||||
]);
|
||||
case "show-precedence":
|
||||
return this.formatPreview([
|
||||
"Read order:",
|
||||
"1. ~/.config/mcp/mcp.json",
|
||||
"2. <Pi agent dir>/mcp.json",
|
||||
"3. .mcp.json",
|
||||
`4. ${CONFIG_DIR_NAME}/mcp.json`,
|
||||
"Pi writes compatibility imports and adapter-only overrides to Pi-owned files.",
|
||||
]);
|
||||
case "open-paths":
|
||||
return this.formatPreview(this.getDetectedPaths().length > 0
|
||||
? ["Detected paths:", ...this.getDetectedPaths()]
|
||||
: ["No config paths were detected."]);
|
||||
case "add-repoprompt": {
|
||||
const repoPrompt = this.discovery.repoPrompt;
|
||||
const preview = this.callbacks.previewRepoPrompt();
|
||||
if (!preview) {
|
||||
return this.formatPreview(["RepoPrompt is not available to add from this setup screen."]);
|
||||
}
|
||||
return this.formatWritePreview(
|
||||
"RepoPrompt write preview",
|
||||
preview,
|
||||
[
|
||||
`Executable: ${repoPrompt.executablePath ?? "not found"}`,
|
||||
`Target: ${repoPrompt.targetPath ?? "n/a"}`,
|
||||
`Server name: ${repoPrompt.serverName ?? "repoprompt"}`,
|
||||
],
|
||||
);
|
||||
}
|
||||
case "scaffold-project":
|
||||
return this.formatWritePreview(
|
||||
"Starter project `.mcp.json` write preview",
|
||||
this.callbacks.previewStarterProject(),
|
||||
[
|
||||
"This writes a minimal `.mcp.json` in the current project using the shared MCP layout.",
|
||||
"It intentionally avoids adding a fake placeholder server that would fail on first reload.",
|
||||
],
|
||||
);
|
||||
case "close":
|
||||
default:
|
||||
return this.formatPreview(["Close the setup flow."]);
|
||||
}
|
||||
}
|
||||
|
||||
private formatPreview(lines: string[]): string[] {
|
||||
const preview: string[] = [];
|
||||
for (const line of lines) {
|
||||
preview.push(...wrapText(line, 74));
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
private formatWritePreview(title: string, preview: ConfigWritePreview, intro: string[] = []): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const line of intro) {
|
||||
lines.push(...wrapText(line, 74));
|
||||
}
|
||||
if (intro.length > 0) lines.push("");
|
||||
lines.push(...wrapText(`${title}: ${preview.path}`, 74));
|
||||
lines.push(...wrapText(preview.existed ? "Existing file detected. Showing exact before/after diff." : "New file will be created. Showing exact content diff.", 74));
|
||||
lines.push("");
|
||||
const diffLines = preview.diffText.split("\n");
|
||||
const maxLines = 18;
|
||||
const shown = diffLines.slice(0, maxLines);
|
||||
for (const line of shown) {
|
||||
lines.push(...wrapText(line, 74));
|
||||
}
|
||||
if (diffLines.length > maxLines) {
|
||||
lines.push(...wrapText(`… ${diffLines.length - maxLines} more diff line${diffLines.length - maxLines === 1 ? "" : "s"}`, 74));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private padLine(text: string, innerW: number): string {
|
||||
const inset = 2;
|
||||
const contentW = Math.max(0, innerW - inset * 2);
|
||||
const fitted = truncateToWidth(text, contentW, "…", true);
|
||||
const plainWidth = visibleWidth(fitted);
|
||||
const padding = Math.max(0, contentW - plainWidth);
|
||||
return `│${" ".repeat(inset)}${fitted}${" ".repeat(padding)}${" ".repeat(inset)}│`;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
dispose(): void {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export function createMcpSetupPanel(
|
||||
discovery: McpDiscoverySummary,
|
||||
callbacks: SetupPanelCallbacks,
|
||||
options: SetupPanelOptions,
|
||||
tui: { requestRender(): void },
|
||||
done: () => void,
|
||||
): McpSetupPanel & { dispose(): void } {
|
||||
return new McpSetupPanel(discovery, callbacks, options, tui, done);
|
||||
}
|
||||
201
agent/extensions/pi-mcp-adapter/metadata-cache.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
// metadata-cache.ts - Persistent MCP metadata cache
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { getAgentPath } from "./agent-dir.ts";
|
||||
import { createHash } from "node:crypto";
|
||||
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { McpTool, McpResource, ServerEntry, ToolMetadata } from "./types.ts";
|
||||
import { formatToolName, isToolExcluded } from "./types.ts";
|
||||
import { resourceNameToToolName } from "./resource-tools.ts";
|
||||
import { extractToolUiStreamMode, interpolateEnvRecord, resolveBearerToken, resolveConfigPath } from "./utils.ts";
|
||||
|
||||
const CACHE_VERSION = 1;
|
||||
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface CachedTool {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: unknown;
|
||||
uiResourceUri?: string;
|
||||
uiStreamMode?: "eager" | "stream-first";
|
||||
}
|
||||
|
||||
export interface CachedResource {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ServerCacheEntry {
|
||||
configHash: string;
|
||||
tools: CachedTool[];
|
||||
resources: CachedResource[];
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
export interface MetadataCache {
|
||||
version: number;
|
||||
servers: Record<string, ServerCacheEntry>;
|
||||
}
|
||||
|
||||
export function getMetadataCachePath(): string {
|
||||
return getAgentPath("mcp-cache.json");
|
||||
}
|
||||
|
||||
export function loadMetadataCache(): MetadataCache | null {
|
||||
const cachePath = getMetadataCachePath();
|
||||
if (!existsSync(cachePath)) return null;
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(cachePath, "utf-8"));
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
if (raw.version !== CACHE_VERSION) return null;
|
||||
if (!raw.servers || typeof raw.servers !== "object") return null;
|
||||
return raw as MetadataCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveMetadataCache(cache: MetadataCache): void {
|
||||
const cachePath = getMetadataCachePath();
|
||||
const dir = dirname(cachePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
let merged: MetadataCache = { version: CACHE_VERSION, servers: {} };
|
||||
try {
|
||||
if (existsSync(cachePath)) {
|
||||
const existing = JSON.parse(readFileSync(cachePath, "utf-8")) as MetadataCache;
|
||||
if (existing && existing.version === CACHE_VERSION && existing.servers) {
|
||||
merged.servers = { ...existing.servers };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors and proceed with empty cache
|
||||
}
|
||||
|
||||
merged.version = CACHE_VERSION;
|
||||
merged.servers = { ...merged.servers, ...cache.servers };
|
||||
|
||||
const tmpPath = `${cachePath}.${process.pid}.tmp`;
|
||||
writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
|
||||
renameSync(tmpPath, cachePath);
|
||||
}
|
||||
|
||||
export function computeServerHash(definition: ServerEntry): string {
|
||||
// Hash only fields that affect server identity and tool/resource output.
|
||||
// Exclude lifecycle, idleTimeout, debug — those are runtime behavior settings
|
||||
// that don't change which tools a server exposes.
|
||||
const identity: Record<string, unknown> = {
|
||||
command: definition.command,
|
||||
args: definition.args,
|
||||
env: interpolateEnvRecord(definition.env),
|
||||
cwd: resolveConfigPath(definition.cwd),
|
||||
url: definition.url,
|
||||
headers: interpolateEnvRecord(definition.headers),
|
||||
auth: definition.auth,
|
||||
bearerToken: resolveBearerToken(definition),
|
||||
bearerTokenEnv: definition.bearerTokenEnv,
|
||||
exposeResources: definition.exposeResources,
|
||||
excludeTools: definition.excludeTools,
|
||||
};
|
||||
const normalized = stableStringify(identity);
|
||||
return createHash("sha256").update(normalized).digest("hex");
|
||||
}
|
||||
|
||||
export function isServerCacheValid(
|
||||
entry: ServerCacheEntry,
|
||||
definition: ServerEntry,
|
||||
maxAgeMs: number = CACHE_MAX_AGE_MS
|
||||
): boolean {
|
||||
if (!entry || entry.configHash !== computeServerHash(definition)) return false;
|
||||
if (!entry.cachedAt || typeof entry.cachedAt !== "number") return false;
|
||||
if (maxAgeMs > 0 && Date.now() - entry.cachedAt > maxAgeMs) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function reconstructToolMetadata(
|
||||
serverName: string,
|
||||
entry: ServerCacheEntry,
|
||||
prefix: "server" | "none" | "short",
|
||||
definition: Pick<ServerEntry, "exposeResources" | "excludeTools">
|
||||
): ToolMetadata[] {
|
||||
const metadata: ToolMetadata[] = [];
|
||||
|
||||
for (const tool of entry.tools ?? []) {
|
||||
if (!tool?.name) continue;
|
||||
if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata.push({
|
||||
name: formatToolName(tool.name, serverName, prefix),
|
||||
originalName: tool.name,
|
||||
description: tool.description ?? "",
|
||||
inputSchema: tool.inputSchema,
|
||||
uiResourceUri: tool.uiResourceUri,
|
||||
uiStreamMode: tool.uiStreamMode,
|
||||
});
|
||||
}
|
||||
|
||||
if (definition.exposeResources !== false) {
|
||||
for (const resource of entry.resources ?? []) {
|
||||
if (!resource?.name || !resource?.uri) continue;
|
||||
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
||||
if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata.push({
|
||||
name: formatToolName(baseName, serverName, prefix),
|
||||
originalName: baseName,
|
||||
description: resource.description ?? `Read resource: ${resource.uri}`,
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function serializeTools(tools: McpTool[]): CachedTool[] {
|
||||
return tools
|
||||
.filter(t => t?.name)
|
||||
.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
uiResourceUri: tryGetToolUiResourceUri(t),
|
||||
uiStreamMode: extractToolUiStreamMode(t._meta),
|
||||
}));
|
||||
}
|
||||
|
||||
export function serializeResources(resources: McpResource[]): CachedResource[] {
|
||||
return resources
|
||||
.filter(r => r?.name && r?.uri)
|
||||
.map(r => ({
|
||||
uri: r.uri,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
}));
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || value === undefined || typeof value !== "object") {
|
||||
const serialized = JSON.stringify(value);
|
||||
return serialized === undefined ? "undefined" : serialized;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(v => stableStringify(v)).join(",")}]`;
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
function tryGetToolUiResourceUri(tool: McpTool): string | undefined {
|
||||
try {
|
||||
return getToolUiResourceUri({ _meta: tool._meta });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
424
agent/extensions/pi-mcp-adapter/npx-resolver.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
// npx-resolver.ts - Resolve npx/npm exec binaries to avoid npm parent processes
|
||||
import { existsSync, readFileSync, realpathSync, readdirSync, statSync, writeFileSync, renameSync, mkdirSync, openSync, readSync, closeSync } from "node:fs";
|
||||
import { join, dirname, extname, resolve, sep } from "node:path";
|
||||
import { getAgentPath } from "./agent-dir.ts";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
|
||||
const CACHE_VERSION = 1;
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface NpxCacheEntry {
|
||||
resolvedBin: string;
|
||||
resolvedAt: number;
|
||||
packageVersion?: string;
|
||||
isJs: boolean;
|
||||
}
|
||||
|
||||
interface NpxCache {
|
||||
version: number;
|
||||
entries: Record<string, NpxCacheEntry>;
|
||||
}
|
||||
|
||||
export interface NpxResolution {
|
||||
binPath: string;
|
||||
extraArgs: string[];
|
||||
isJs: boolean;
|
||||
}
|
||||
|
||||
interface ParsedInvocation {
|
||||
packageSpec: string;
|
||||
binName?: string;
|
||||
extraArgs: string[];
|
||||
}
|
||||
|
||||
export async function resolveNpxBinary(
|
||||
command: string,
|
||||
args: string[]
|
||||
): Promise<NpxResolution | null> {
|
||||
const parsed = command === "npx"
|
||||
? parseNpxArgs(args)
|
||||
: command === "npm"
|
||||
? parseNpmExecArgs(args)
|
||||
: null;
|
||||
|
||||
if (!parsed) return null;
|
||||
|
||||
const cacheKey = JSON.stringify([command, ...args]);
|
||||
const cache = loadCache();
|
||||
const cached = cache?.entries?.[cacheKey];
|
||||
|
||||
if (cached && Date.now() - cached.resolvedAt < CACHE_TTL_MS && existsSync(cached.resolvedBin)) {
|
||||
return { binPath: cached.resolvedBin, extraArgs: parsed.extraArgs, isJs: cached.isJs };
|
||||
}
|
||||
|
||||
const resolved = resolveFromNpmCache(parsed.packageSpec, parsed.binName);
|
||||
if (resolved) {
|
||||
saveCacheEntry(cacheKey, resolved);
|
||||
return { binPath: resolved.resolvedBin, extraArgs: parsed.extraArgs, isJs: resolved.isJs };
|
||||
}
|
||||
|
||||
// Slow path: force npx cache population
|
||||
await forceNpxCache(parsed.packageSpec);
|
||||
const resolvedAfterInstall = resolveFromNpmCache(parsed.packageSpec, parsed.binName);
|
||||
if (resolvedAfterInstall) {
|
||||
saveCacheEntry(cacheKey, resolvedAfterInstall);
|
||||
return { binPath: resolvedAfterInstall.resolvedBin, extraArgs: parsed.extraArgs, isJs: resolvedAfterInstall.isJs };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseNpxArgs(args: string[]): ParsedInvocation | null {
|
||||
const separatorIndex = args.indexOf("--");
|
||||
const before = separatorIndex >= 0 ? args.slice(0, separatorIndex) : args;
|
||||
const after = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : [];
|
||||
|
||||
const positionals: string[] = [];
|
||||
let packageSpec: string | undefined;
|
||||
let sawPackageFlag = false;
|
||||
let foundFirstPositional = false;
|
||||
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
const arg = before[i];
|
||||
if (foundFirstPositional) {
|
||||
positionals.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === "-y" || arg === "--yes") continue;
|
||||
if (arg === "-p" || arg === "--package") {
|
||||
const value = before[i + 1];
|
||||
if (!value || value.startsWith("-")) return null;
|
||||
if (!packageSpec) packageSpec = value;
|
||||
sawPackageFlag = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--package=")) {
|
||||
const value = arg.slice("--package=".length);
|
||||
if (!value) return null;
|
||||
if (!packageSpec) packageSpec = value;
|
||||
sawPackageFlag = true;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
positionals.push(arg);
|
||||
foundFirstPositional = true;
|
||||
}
|
||||
|
||||
if (sawPackageFlag) {
|
||||
const binName = positionals[0];
|
||||
if (!packageSpec || !binName) return null;
|
||||
const extraArgs = positionals.slice(1).concat(after);
|
||||
return { packageSpec, binName, extraArgs };
|
||||
}
|
||||
|
||||
const packagePositional = positionals[0];
|
||||
if (!packagePositional) return null;
|
||||
const extraArgs = positionals.slice(1).concat(after);
|
||||
return { packageSpec: packagePositional, extraArgs };
|
||||
}
|
||||
|
||||
function parseNpmExecArgs(args: string[]): ParsedInvocation | null {
|
||||
if (args[0] !== "exec") return null;
|
||||
const execArgs = args.slice(1);
|
||||
const separatorIndex = execArgs.indexOf("--");
|
||||
if (separatorIndex < 0) return null;
|
||||
|
||||
const before = execArgs.slice(0, separatorIndex);
|
||||
const after = execArgs.slice(separatorIndex + 1);
|
||||
|
||||
let packageSpec: string | undefined;
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
const arg = before[i];
|
||||
if (arg === "-y" || arg === "--yes") continue;
|
||||
if (arg === "--package") {
|
||||
const value = before[i + 1];
|
||||
if (!value || value.startsWith("-")) return null;
|
||||
if (!packageSpec) packageSpec = value;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--package=")) {
|
||||
const value = arg.slice("--package=".length);
|
||||
if (!value) return null;
|
||||
if (!packageSpec) packageSpec = value;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const binName = after[0];
|
||||
if (!packageSpec || !binName) return null;
|
||||
const extraArgs = after.slice(1);
|
||||
return { packageSpec, binName, extraArgs };
|
||||
}
|
||||
|
||||
function resolveFromNpmCache(packageSpec: string, binName?: string): NpxCacheEntry | null {
|
||||
const cacheDir = getNpmCacheDir();
|
||||
if (!cacheDir) return null;
|
||||
|
||||
const packageName = extractPackageName(packageSpec);
|
||||
if (!packageName) return null;
|
||||
|
||||
const packageDir = findCachedPackageDir(cacheDir, packageName);
|
||||
if (!packageDir) return null;
|
||||
|
||||
const packageJsonPath = join(packageDir, "package.json");
|
||||
if (!existsSync(packageJsonPath)) return null;
|
||||
|
||||
let pkg: { bin?: string | Record<string, string>; version?: string } | null = null;
|
||||
try {
|
||||
pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
||||
bin?: string | Record<string, string>;
|
||||
version?: string;
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const binField = pkg?.bin;
|
||||
if (!binField) return null;
|
||||
|
||||
const candidates = buildBinCandidates(packageName, binName);
|
||||
let chosenBinName: string | undefined;
|
||||
let binRel: string | undefined;
|
||||
|
||||
if (typeof binField === "string") {
|
||||
chosenBinName = defaultBinName(packageName);
|
||||
binRel = binField;
|
||||
} else {
|
||||
for (const candidate of candidates) {
|
||||
if (binField[candidate]) {
|
||||
chosenBinName = candidate;
|
||||
binRel = binField[candidate];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!binRel) {
|
||||
const firstEntry = Object.entries(binField)[0];
|
||||
if (firstEntry) {
|
||||
chosenBinName = firstEntry[0];
|
||||
binRel = firstEntry[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!binRel) return null;
|
||||
|
||||
const nodeModulesDir = findNodeModulesDir(packageDir);
|
||||
const binLink = chosenBinName ? join(nodeModulesDir, ".bin", chosenBinName) : null;
|
||||
let resolvedBin = binLink && existsSync(binLink) ? safeRealpath(binLink) : "";
|
||||
if (!resolvedBin) {
|
||||
resolvedBin = resolve(packageDir, binRel);
|
||||
if (!existsSync(resolvedBin)) return null;
|
||||
}
|
||||
|
||||
const isJs = detectJsBinary(resolvedBin);
|
||||
return {
|
||||
resolvedBin,
|
||||
resolvedAt: Date.now(),
|
||||
packageVersion: pkg?.version,
|
||||
isJs,
|
||||
};
|
||||
}
|
||||
|
||||
const FORCE_CACHE_TIMEOUT_MS = 30_000;
|
||||
|
||||
async function forceNpxCache(packageSpec: string): Promise<void> {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
"npm",
|
||||
["exec", "--yes", "--package", packageSpec, "--", "node", "-e", "1"],
|
||||
{ stdio: "ignore" }
|
||||
);
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error("timeout"));
|
||||
}, FORCE_CACHE_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
proc.on("close", () => { clearTimeout(timer); resolve(); });
|
||||
proc.on("error", (err) => { clearTimeout(timer); reject(err); });
|
||||
});
|
||||
} catch {
|
||||
// Ignore failures, resolution will fall back to original command
|
||||
}
|
||||
}
|
||||
|
||||
function buildBinCandidates(packageName: string, explicitBin?: string): string[] {
|
||||
const candidates: string[] = [];
|
||||
if (explicitBin) candidates.push(explicitBin);
|
||||
|
||||
if (packageName.startsWith("@")) {
|
||||
const namePart = packageName.split("/")[1] ?? "";
|
||||
const scopePart = packageName.split("/")[0]?.replace("@", "") ?? "";
|
||||
if (namePart) candidates.push(namePart);
|
||||
if (scopePart && namePart) candidates.push(`${scopePart}-${namePart}`);
|
||||
} else {
|
||||
candidates.push(packageName);
|
||||
}
|
||||
|
||||
return [...new Set(candidates.filter(Boolean))];
|
||||
}
|
||||
|
||||
function extractPackageName(spec: string): string | null {
|
||||
const trimmed = spec.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith("@")) {
|
||||
const slashIndex = trimmed.indexOf("/");
|
||||
if (slashIndex < 0) return null;
|
||||
const atIndex = trimmed.lastIndexOf("@");
|
||||
if (atIndex > slashIndex) {
|
||||
return trimmed.slice(0, atIndex);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
const atIndex = trimmed.indexOf("@");
|
||||
return atIndex >= 0 ? trimmed.slice(0, atIndex) : trimmed;
|
||||
}
|
||||
|
||||
function defaultBinName(packageName: string): string {
|
||||
if (packageName.startsWith("@")) {
|
||||
const parts = packageName.split("/");
|
||||
return parts[1] ?? packageName.replace("@", "").replace("/", "-");
|
||||
}
|
||||
return packageName;
|
||||
}
|
||||
|
||||
function findCachedPackageDir(cacheDir: string, packageName: string): string | null {
|
||||
const npxDir = join(cacheDir, "_npx");
|
||||
if (!existsSync(npxDir)) return null;
|
||||
|
||||
const packagePathParts = packageName.startsWith("@")
|
||||
? packageName.split("/")
|
||||
: [packageName];
|
||||
|
||||
const candidates = readdirSync(npxDir, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => {
|
||||
const full = join(npxDir, entry.name);
|
||||
const mtime = safeStatMtime(full);
|
||||
return { name: entry.name, mtime };
|
||||
})
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
for (const entry of candidates) {
|
||||
const pkgDir = join(npxDir, entry.name, "node_modules", ...packagePathParts);
|
||||
if (existsSync(join(pkgDir, "package.json"))) {
|
||||
return pkgDir;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNodeModulesDir(packageDir: string): string {
|
||||
const parts = packageDir.split(sep);
|
||||
const idx = parts.lastIndexOf("node_modules");
|
||||
if (idx >= 0) {
|
||||
return parts.slice(0, idx + 1).join(sep);
|
||||
}
|
||||
return join(packageDir, "..");
|
||||
}
|
||||
|
||||
function detectJsBinary(binPath: string): boolean {
|
||||
const ext = extname(binPath).toLowerCase();
|
||||
if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return true;
|
||||
try {
|
||||
const fd = openSync(binPath, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(256);
|
||||
readSync(fd, buf, 0, 256, 0);
|
||||
const firstLine = buf.toString("utf-8").split("\n")[0] ?? "";
|
||||
return firstLine.startsWith("#!") && firstLine.includes("node");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let npmCacheDirCached: string | null | undefined;
|
||||
|
||||
function getNpmCacheDir(): string | null {
|
||||
if (npmCacheDirCached !== undefined) return npmCacheDirCached;
|
||||
if (process.env.NPM_CONFIG_CACHE) {
|
||||
npmCacheDirCached = process.env.NPM_CONFIG_CACHE;
|
||||
return npmCacheDirCached;
|
||||
}
|
||||
try {
|
||||
const result = spawnSync("npm", ["config", "get", "cache"], { encoding: "utf-8" });
|
||||
if (result.status === 0) {
|
||||
const path = String(result.stdout).trim();
|
||||
npmCacheDirCached = path || null;
|
||||
return npmCacheDirCached;
|
||||
}
|
||||
} catch {
|
||||
npmCacheDirCached = null;
|
||||
return null;
|
||||
}
|
||||
npmCacheDirCached = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getNpxCachePath(): string {
|
||||
return getAgentPath("mcp-npx-cache.json");
|
||||
}
|
||||
|
||||
function loadCache(): NpxCache | null {
|
||||
const cachePath = getNpxCachePath();
|
||||
if (!existsSync(cachePath)) return null;
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(cachePath, "utf-8"));
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
if (raw.version !== CACHE_VERSION) return null;
|
||||
if (!raw.entries || typeof raw.entries !== "object") return null;
|
||||
return raw as NpxCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveCacheEntry(key: string, entry: NpxCacheEntry): void {
|
||||
const cachePath = getNpxCachePath();
|
||||
const dir = dirname(cachePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
let merged: NpxCache = { version: CACHE_VERSION, entries: {} };
|
||||
try {
|
||||
if (existsSync(cachePath)) {
|
||||
const existing = JSON.parse(readFileSync(cachePath, "utf-8")) as NpxCache;
|
||||
if (existing && existing.version === CACHE_VERSION && existing.entries) {
|
||||
merged.entries = { ...existing.entries };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
merged.entries[key] = entry;
|
||||
const tmpPath = `${cachePath}.${process.pid}.tmp`;
|
||||
writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
|
||||
renameSync(tmpPath, cachePath);
|
||||
}
|
||||
|
||||
function safeRealpath(path: string): string {
|
||||
try {
|
||||
return realpathSync(path);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function safeStatMtime(path: string): number {
|
||||
try {
|
||||
return statSync(path).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
57
agent/extensions/pi-mcp-adapter/oauth-handler.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// oauth-handler.ts - OAuth token management for MCP servers
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { getAuthEntryFilePath } from "./mcp-auth.ts";
|
||||
|
||||
// Token storage path for a server
|
||||
function getTokensPath(serverName: string): string {
|
||||
return getAuthEntryFilePath(serverName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored OAuth tokens for a server (if any).
|
||||
* Returns undefined if no tokens or tokens are expired.
|
||||
*
|
||||
* Token file location: $MCP_OAUTH_DIR/sha256-<server-hash>/tokens.json when set,
|
||||
* otherwise <Pi agent dir>/mcp-oauth/sha256-<server-hash>/tokens.json
|
||||
*
|
||||
* Expected format:
|
||||
* {
|
||||
* "access_token": "...",
|
||||
* "token_type": "bearer",
|
||||
* "refresh_token": "...", // optional
|
||||
* "expires_in": 3600, // optional, seconds
|
||||
* "expiresAt": 1234567890 // optional, absolute timestamp ms
|
||||
* }
|
||||
*/
|
||||
export function getStoredTokens(serverName: string): OAuthTokens | undefined {
|
||||
const tokensPath = getTokensPath(serverName);
|
||||
|
||||
if (!existsSync(tokensPath)) return undefined;
|
||||
|
||||
try {
|
||||
const stored = JSON.parse(readFileSync(tokensPath, "utf-8"));
|
||||
|
||||
// Validate required field
|
||||
if (!stored.access_token || typeof stored.access_token !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check expiration if expiresAt is set
|
||||
if (stored.expiresAt && typeof stored.expiresAt === "number") {
|
||||
if (Date.now() > stored.expiresAt) {
|
||||
// Token expired
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: stored.access_token,
|
||||
token_type: stored.token_type ?? "bearer",
|
||||
refresh_token: stored.refresh_token,
|
||||
expires_in: stored.expires_in,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
68
agent/extensions/pi-mcp-adapter/onboarding-state.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { getAgentPath } from "./agent-dir.ts";
|
||||
|
||||
export interface McpOnboardingState {
|
||||
version: 1;
|
||||
sharedConfigHintShown: boolean;
|
||||
setupCompleted: boolean;
|
||||
lastDiscoveryFingerprint?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: McpOnboardingState = {
|
||||
version: 1,
|
||||
sharedConfigHintShown: false,
|
||||
setupCompleted: false,
|
||||
};
|
||||
|
||||
export function getOnboardingStatePath(): string {
|
||||
return getAgentPath("mcp-onboarding.json");
|
||||
}
|
||||
|
||||
export function loadOnboardingState(): McpOnboardingState {
|
||||
const path = getOnboardingStatePath();
|
||||
if (!existsSync(path)) return { ...DEFAULT_STATE };
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(path, "utf-8")) as Partial<McpOnboardingState>;
|
||||
if (!raw || typeof raw !== "object") return { ...DEFAULT_STATE };
|
||||
return {
|
||||
version: 1,
|
||||
sharedConfigHintShown: raw.sharedConfigHintShown === true,
|
||||
setupCompleted: raw.setupCompleted === true,
|
||||
lastDiscoveryFingerprint: typeof raw.lastDiscoveryFingerprint === "string" ? raw.lastDiscoveryFingerprint : undefined,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_STATE };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveOnboardingState(state: McpOnboardingState): void {
|
||||
const path = getOnboardingStatePath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmpPath = `${path}.${process.pid}.tmp`;
|
||||
writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
|
||||
renameSync(tmpPath, path);
|
||||
}
|
||||
|
||||
export function updateOnboardingState(updater: (state: McpOnboardingState) => McpOnboardingState): McpOnboardingState {
|
||||
const next = updater(loadOnboardingState());
|
||||
saveOnboardingState(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function markSharedConfigHintShown(fingerprint?: string): McpOnboardingState {
|
||||
return updateOnboardingState((state) => ({
|
||||
...state,
|
||||
sharedConfigHintShown: true,
|
||||
lastDiscoveryFingerprint: fingerprint ?? state.lastDiscoveryFingerprint,
|
||||
}));
|
||||
}
|
||||
|
||||
export function markSetupCompleted(fingerprint?: string): McpOnboardingState {
|
||||
return updateOnboardingState((state) => ({
|
||||
...state,
|
||||
setupCompleted: true,
|
||||
lastDiscoveryFingerprint: fingerprint ?? state.lastDiscoveryFingerprint,
|
||||
}));
|
||||
}
|
||||
106
agent/extensions/pi-mcp-adapter/package.json
Normal file
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"name": "pi-mcp-adapter",
|
||||
"version": "2.10.0",
|
||||
"description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Nico Bailon",
|
||||
"bin": {
|
||||
"pi-mcp-adapter": "cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:oauth-provider": "node --import tsx --test mcp-oauth-provider.test.ts"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/nicobailon/pi-mcp-adapter.git"
|
||||
},
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"pi",
|
||||
"mcp",
|
||||
"model-context-protocol",
|
||||
"ai",
|
||||
"coding-agent",
|
||||
"extension",
|
||||
"claude",
|
||||
"llm"
|
||||
],
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"video": "https://github.com/nicobailon/pi-mcp-adapter/raw/refs/heads/main/pi-mcp.mp4"
|
||||
},
|
||||
"files": [
|
||||
"cli.js",
|
||||
"agent-dir.ts",
|
||||
"index.ts",
|
||||
"state.ts",
|
||||
"utils.ts",
|
||||
"tool-metadata.ts",
|
||||
"init.ts",
|
||||
"ui-session.ts",
|
||||
"proxy-modes.ts",
|
||||
"direct-tools.ts",
|
||||
"commands.ts",
|
||||
"onboarding-state.ts",
|
||||
"mcp-setup-panel.ts",
|
||||
"types.ts",
|
||||
"ui-stream-types.ts",
|
||||
"config.ts",
|
||||
"server-manager.ts",
|
||||
"sampling-handler.ts",
|
||||
"elicitation-handler.ts",
|
||||
"tool-registrar.ts",
|
||||
"tool-result-renderer.ts",
|
||||
"resource-tools.ts",
|
||||
"lifecycle.ts",
|
||||
"metadata-cache.ts",
|
||||
"host-html-template.ts",
|
||||
"ui-resource-handler.ts",
|
||||
"consent-manager.ts",
|
||||
"ui-server.ts",
|
||||
"glimpse-ui.ts",
|
||||
"npx-resolver.ts",
|
||||
"oauth-handler.ts",
|
||||
"mcp-auth.ts",
|
||||
"mcp-oauth-provider.ts",
|
||||
"mcp-callback-server.ts",
|
||||
"mcp-auth-flow.ts",
|
||||
"mcp-panel.ts",
|
||||
"panel-keys.ts",
|
||||
"logger.ts",
|
||||
"errors.ts",
|
||||
"app-bridge.bundle.js",
|
||||
"banner.png",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.74.0",
|
||||
"@earendil-works/pi-tui": "^0.74.0",
|
||||
"@modelcontextprotocol/ext-apps": "^1.2.2",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"open": "^10.2.0",
|
||||
"recheck": "^4.5.0",
|
||||
"typebox": "^1.1.24",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "^0.79.1",
|
||||
"@types/bun": "^1.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/open": "^6.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
37
agent/extensions/pi-mcp-adapter/panel-keys.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { matchesKey } from "@earendil-works/pi-tui";
|
||||
|
||||
/** The `tui.select.*` keybinding ids the adapter panels resolve. */
|
||||
export type PanelSelectKeybinding =
|
||||
| "tui.select.up"
|
||||
| "tui.select.down"
|
||||
| "tui.select.confirm";
|
||||
|
||||
/** Structural subset of pi-tui's `KeybindingsManager` (which satisfies it). */
|
||||
export interface PanelKeybindings {
|
||||
matches(data: string, keybinding: PanelSelectKeybinding): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key matchers for list navigation: user's `tui.select.*` bindings when a
|
||||
* manager is provided, otherwise the previous hardcoded defaults.
|
||||
*/
|
||||
export interface PanelKeys {
|
||||
selectUp(data: string): boolean;
|
||||
selectDown(data: string): boolean;
|
||||
selectConfirm(data: string): boolean;
|
||||
}
|
||||
|
||||
export function createPanelKeys(keybindings?: PanelKeybindings): PanelKeys {
|
||||
if (keybindings) {
|
||||
return {
|
||||
selectUp: (data) => keybindings.matches(data, "tui.select.up"),
|
||||
selectDown: (data) => keybindings.matches(data, "tui.select.down"),
|
||||
selectConfirm: (data) => keybindings.matches(data, "tui.select.confirm"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
selectUp: (data) => matchesKey(data, "up"),
|
||||
selectDown: (data) => matchesKey(data, "down"),
|
||||
selectConfirm: (data) => matchesKey(data, "return"),
|
||||
};
|
||||
}
|
||||
BIN
agent/extensions/pi-mcp-adapter/pi-mcp.mp4
Normal file
949
agent/extensions/pi-mcp-adapter/proxy-modes.ts
Normal file
@@ -0,0 +1,949 @@
|
||||
import type { AgentToolResult, ToolInfo } from "@earendil-works/pi-coding-agent";
|
||||
import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { checkSync } from "recheck";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import type { ToolMetadata, McpContent } from "./types.ts";
|
||||
import { getServerPrefix, parseUiPromptHandoff } from "./types.ts";
|
||||
import { lazyConnect, updateServerMetadata, updateMetadataCache, getFailureAgeSeconds, updateStatusBar } from "./init.ts";
|
||||
import { buildToolMetadata, getToolNames, findToolByName, formatSchema } from "./tool-metadata.ts";
|
||||
import { transformMcpContent } from "./tool-registrar.ts";
|
||||
import { maybeStartUiSession, type UiSessionRuntime } from "./ui-session.ts";
|
||||
import { formatAuthRequiredMessage, truncateAtWord } from "./utils.ts";
|
||||
import { authenticate, completeAuthFromInput, startAuth, supportsOAuth } from "./mcp-auth-flow.ts";
|
||||
|
||||
type ProxyToolResult = AgentToolResult<Record<string, unknown>>;
|
||||
|
||||
const MAX_REGEX_SEARCH_QUERY_LENGTH = 256;
|
||||
const REGEX_SAFETY_CHECK_PARAMS = {
|
||||
attackTimeout: 50,
|
||||
incubationTimeout: 50,
|
||||
timeout: 250,
|
||||
} as const;
|
||||
|
||||
type AutoAuthResult =
|
||||
| { status: "skipped" }
|
||||
| { status: "success" }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
function getAuthRequiredMessage(
|
||||
state: McpExtensionState,
|
||||
serverName: string,
|
||||
defaultMessage = `Server "${serverName}" requires OAuth authentication. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`,
|
||||
): string {
|
||||
return formatAuthRequiredMessage(state.config, serverName, defaultMessage);
|
||||
}
|
||||
|
||||
function getAuthFailedMessage(state: McpExtensionState, serverName: string, message: string): string {
|
||||
const customGuidance = state.config.settings?.authRequiredMessage;
|
||||
if (customGuidance) {
|
||||
return `OAuth authentication failed for "${serverName}": ${message}. ${getAuthRequiredMessage(state, serverName)}`;
|
||||
}
|
||||
return `OAuth authentication failed for "${serverName}": ${message}. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`;
|
||||
}
|
||||
|
||||
function getRedirectPort(authorizationUrl: string): number | undefined {
|
||||
try {
|
||||
const redirectUri = new URL(authorizationUrl).searchParams.get("redirect_uri");
|
||||
if (!redirectUri) return undefined;
|
||||
const port = Number.parseInt(new URL(redirectUri).port, 10);
|
||||
return Number.isInteger(port) ? port : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatManualAuthInstructions(serverName: string, authorizationUrl: string): string {
|
||||
const port = getRedirectPort(authorizationUrl);
|
||||
const portNote = port
|
||||
? `\nThe redirect URL will use local port ${port}. On a remote server it is expected for that localhost page to fail locally; copy the address bar URL anyway.`
|
||||
: "";
|
||||
|
||||
return [
|
||||
`MCP OAuth required for "${serverName}".`,
|
||||
"",
|
||||
"Open this URL in your local browser:",
|
||||
"",
|
||||
authorizationUrl,
|
||||
"",
|
||||
"After approving, copy the full redirected localhost URL from your browser address bar and send it back with:",
|
||||
`mcp({ action: "auth-complete", server: "${serverName}", args: '{"redirectUrl":"PASTE_REDIRECT_URL_HERE"}' })`,
|
||||
"",
|
||||
"You can also pass just the `code` query parameter as `args: '{\"code\":\"PASTE_CODE_HERE\"}'`.",
|
||||
portNote.trimEnd(),
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
async function attemptAutoAuth(
|
||||
state: McpExtensionState,
|
||||
serverName: string,
|
||||
): Promise<AutoAuthResult> {
|
||||
if (state.config.settings?.autoAuth !== true) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition || !supportsOAuth(definition) || !definition.url) {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
const grantType = definition.oauth ? definition.oauth.grantType ?? "authorization_code" : "authorization_code";
|
||||
if (!state.ui && grantType !== "client_credentials") {
|
||||
return {
|
||||
status: "failed",
|
||||
message: getAuthRequiredMessage(
|
||||
state,
|
||||
serverName,
|
||||
`Server "${serverName}" requires OAuth authentication. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await authenticate(serverName, definition.url, definition);
|
||||
return { status: "success" };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
status: "failed",
|
||||
message: getAuthFailedMessage(state, serverName, message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function executeUiMessages(state: McpExtensionState): ProxyToolResult {
|
||||
const sessions = state.completedUiSessions;
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "No UI session messages available." }],
|
||||
details: { sessions: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const output: string[] = [];
|
||||
output.push(`UI Session Messages (${sessions.length} session${sessions.length > 1 ? "s" : ""}):\n`);
|
||||
|
||||
const allPrompts: string[] = [];
|
||||
const allIntents = sessions.flatMap((session) => session.messages.intents);
|
||||
const parsedHandoffs: Array<{ intent: string; params: Record<string, unknown>; raw: string }> = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
const timestamp = session.completedAt.toLocaleTimeString();
|
||||
output.push(`\n## ${session.serverName} / ${session.toolName} (${timestamp}, ${session.reason})`);
|
||||
|
||||
const plainPrompts: string[] = [];
|
||||
for (const prompt of session.messages.prompts) {
|
||||
allPrompts.push(prompt);
|
||||
const handoff = parseUiPromptHandoff(prompt);
|
||||
if (handoff) {
|
||||
parsedHandoffs.push(handoff);
|
||||
} else {
|
||||
plainPrompts.push(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
if (plainPrompts.length > 0) {
|
||||
output.push("\n### Prompts:");
|
||||
for (const prompt of plainPrompts) {
|
||||
output.push(`- ${prompt}`);
|
||||
}
|
||||
}
|
||||
|
||||
const intentsForSession = [
|
||||
...session.messages.intents,
|
||||
...session.messages.prompts
|
||||
.map((prompt) => parseUiPromptHandoff(prompt))
|
||||
.filter((handoff): handoff is NonNullable<typeof handoff> => !!handoff)
|
||||
.map((handoff) => ({ intent: handoff.intent, params: handoff.params })),
|
||||
];
|
||||
|
||||
if (intentsForSession.length > 0) {
|
||||
output.push("\n### Intents:");
|
||||
for (const intent of intentsForSession) {
|
||||
const params = intent.params ? ` (${JSON.stringify(intent.params)})` : "";
|
||||
output.push(`- ${intent.intent}${params}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (session.messages.notifications.length > 0) {
|
||||
output.push("\n### Notifications:");
|
||||
for (const notification of session.messages.notifications) {
|
||||
output.push(`- ${notification}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const count = sessions.length;
|
||||
state.completedUiSessions = [];
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: output.join("\n") }],
|
||||
details: {
|
||||
sessions: count,
|
||||
prompts: allPrompts,
|
||||
intents: [...allIntents, ...parsedHandoffs.map(({ intent, params }) => ({ intent, params }))],
|
||||
handoffs: parsedHandoffs,
|
||||
cleared: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function executeStatus(state: McpExtensionState): ProxyToolResult {
|
||||
const servers: Array<{ name: string; status: string; toolCount: number; failedAgo: number | null }> = [];
|
||||
|
||||
for (const name of Object.keys(state.config.mcpServers)) {
|
||||
const connection = state.manager.getConnection(name);
|
||||
const metadata = state.toolMetadata.get(name);
|
||||
const toolCount = metadata?.length ?? 0;
|
||||
const failedAgo = getFailureAgeSeconds(state, name);
|
||||
let status = "not connected";
|
||||
if (connection?.status === "connected") {
|
||||
status = "connected";
|
||||
} else if (connection?.status === "needs-auth") {
|
||||
status = "needs-auth";
|
||||
} else if (failedAgo !== null) {
|
||||
status = "failed";
|
||||
} else if (metadata !== undefined) {
|
||||
status = "cached";
|
||||
}
|
||||
|
||||
servers.push({ name, status, toolCount, failedAgo });
|
||||
}
|
||||
|
||||
const totalTools = servers.reduce((sum, s) => sum + s.toolCount, 0);
|
||||
const connectedCount = servers.filter(s => s.status === "connected").length;
|
||||
|
||||
let text = `MCP: ${connectedCount}/${servers.length} servers, ${totalTools} tools\n\n`;
|
||||
for (const server of servers) {
|
||||
if (server.status === "connected") {
|
||||
text += `✓ ${server.name} (${server.toolCount} tools)\n`;
|
||||
continue;
|
||||
}
|
||||
if (server.status === "needs-auth") {
|
||||
text += `⚠ ${server.name} (needs auth)\n`;
|
||||
continue;
|
||||
}
|
||||
if (server.status === "cached") {
|
||||
text += `○ ${server.name} (${server.toolCount} tools, cached)\n`;
|
||||
continue;
|
||||
}
|
||||
if (server.status === "failed") {
|
||||
text += `✗ ${server.name} (failed ${server.failedAgo ?? 0}s ago)\n`;
|
||||
continue;
|
||||
}
|
||||
text += `○ ${server.name} (not connected)\n`;
|
||||
}
|
||||
|
||||
if (servers.length > 0) {
|
||||
text += `\nmcp({ server: "name" }) to list tools, mcp({ search: "..." }) to search`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: text.trim() }],
|
||||
details: { mode: "status", servers, totalTools, connectedCount },
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeAuthStart(state: McpExtensionState, serverName: string): Promise<ProxyToolResult> {
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
|
||||
details: { mode: "auth-start", error: "not_found", server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
if (!definition.url || !supportsOAuth(definition)) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" is not configured for OAuth over HTTP.` }],
|
||||
details: { mode: "auth-start", error: "oauth_not_supported", server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { authorizationUrl } = await startAuth(serverName, definition.url, definition);
|
||||
if (!authorizationUrl) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `OAuth authentication successful for "${serverName}".` }],
|
||||
details: { mode: "auth-start", server: serverName, authenticated: true },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: formatManualAuthInstructions(serverName, authorizationUrl) }],
|
||||
details: { mode: "auth-start", server: serverName, authorizationUrl },
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Failed to start OAuth for "${serverName}": ${message}` }],
|
||||
details: { mode: "auth-start", error: "auth_start_failed", server: serverName, message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeAuthComplete(state: McpExtensionState, serverName: string, input: string): Promise<ProxyToolResult> {
|
||||
if (!state.config.mcpServers[serverName]) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
|
||||
details: { mode: "auth-complete", error: "not_found", server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await completeAuthFromInput(serverName, input);
|
||||
if (status !== "authenticated") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `OAuth authentication did not complete for "${serverName}".` }],
|
||||
details: { mode: "auth-complete", error: "not_authenticated", server: serverName, status },
|
||||
};
|
||||
}
|
||||
|
||||
await state.manager.close(serverName);
|
||||
state.failureTracker.delete(serverName);
|
||||
updateStatusBar(state);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `OAuth authentication successful for "${serverName}". Run mcp({ connect: "${serverName}" }) to connect with the new token.` }],
|
||||
details: { mode: "auth-complete", server: serverName, authenticated: true },
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Failed to complete OAuth for "${serverName}": ${message}` }],
|
||||
details: { mode: "auth-complete", error: "auth_complete_failed", server: serverName, message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function executeDescribe(state: McpExtensionState, toolName: string): ProxyToolResult {
|
||||
let serverName: string | undefined;
|
||||
let toolMeta: ToolMetadata | undefined;
|
||||
|
||||
for (const [server, metadata] of state.toolMetadata.entries()) {
|
||||
const found = findToolByName(metadata, toolName);
|
||||
if (found) {
|
||||
serverName = server;
|
||||
toolMeta = found;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverName || !toolMeta) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Tool "${toolName}" not found. Use mcp({ search: "..." }) to search.` }],
|
||||
details: { mode: "describe", error: "tool_not_found", requestedTool: toolName },
|
||||
};
|
||||
}
|
||||
|
||||
let text = `${toolMeta.name}\n`;
|
||||
text += `Server: ${serverName}\n`;
|
||||
if (toolMeta.resourceUri) {
|
||||
text += `Type: Resource (reads from ${toolMeta.resourceUri})\n`;
|
||||
}
|
||||
text += `\n${toolMeta.description || "(no description)"}\n`;
|
||||
|
||||
if (toolMeta.inputSchema && !toolMeta.resourceUri) {
|
||||
text += `\nParameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
||||
} else if (toolMeta.resourceUri) {
|
||||
text += `\nNo parameters required (resource tool).`;
|
||||
} else {
|
||||
text += `\nNo parameters defined.`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: text.trim() }],
|
||||
details: { mode: "describe", tool: toolMeta, server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
export function executeSearch(
|
||||
state: McpExtensionState,
|
||||
query: string,
|
||||
regex?: boolean,
|
||||
server?: string,
|
||||
includeSchemas?: boolean,
|
||||
): ProxyToolResult {
|
||||
const showSchemas = includeSchemas !== false;
|
||||
|
||||
const matches: Array<{ server: string; tool: ToolMetadata }> = [];
|
||||
|
||||
let pattern: RegExp;
|
||||
try {
|
||||
if (regex) {
|
||||
if (query.length > MAX_REGEX_SEARCH_QUERY_LENGTH) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Regex query is too long; maximum length is ${MAX_REGEX_SEARCH_QUERY_LENGTH} characters.` }],
|
||||
details: { mode: "search", error: "query_too_long", query, maxLength: MAX_REGEX_SEARCH_QUERY_LENGTH },
|
||||
};
|
||||
}
|
||||
|
||||
pattern = new RegExp(query, "i");
|
||||
let safety;
|
||||
try {
|
||||
safety = checkSync(query, "i", REGEX_SAFETY_CHECK_PARAMS);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Regex query rejected because safety analysis failed." }],
|
||||
details: { mode: "search", error: "unsafe_pattern", query, reason: message },
|
||||
};
|
||||
}
|
||||
if (safety.status !== "safe") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Regex query rejected as unsafe (${safety.status}).` }],
|
||||
details: { mode: "search", error: "unsafe_pattern", query, safetyStatus: safety.status },
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const terms = query.trim().split(/\s+/).filter(t => t.length > 0);
|
||||
if (terms.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Search query cannot be empty" }],
|
||||
details: { mode: "search", error: "empty_query" },
|
||||
};
|
||||
}
|
||||
const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
pattern = new RegExp(escaped.join("|"), "i");
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Invalid regex: ${query}` }],
|
||||
details: { mode: "search", error: "invalid_pattern", query },
|
||||
};
|
||||
}
|
||||
|
||||
for (const [serverName, metadata] of state.toolMetadata.entries()) {
|
||||
if (server && serverName !== server) continue;
|
||||
for (const tool of metadata) {
|
||||
if (pattern.test(tool.name) || pattern.test(tool.description)) {
|
||||
matches.push({
|
||||
server: serverName,
|
||||
tool,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalCount = matches.length;
|
||||
|
||||
if (totalCount === 0) {
|
||||
const msg = server
|
||||
? `No tools matching "${query}" in "${server}"`
|
||||
: `No tools matching "${query}"`;
|
||||
return {
|
||||
content: [{ type: "text" as const, text: msg }],
|
||||
details: { mode: "search", matches: [], count: 0, query },
|
||||
};
|
||||
}
|
||||
|
||||
let text = `Found ${totalCount} tool${totalCount === 1 ? "" : "s"} matching "${query}":\n\n`;
|
||||
|
||||
for (const match of matches) {
|
||||
if (showSchemas) {
|
||||
text += `${match.tool.name}\n`;
|
||||
text += ` ${match.tool.description || "(no description)"}\n`;
|
||||
if (match.tool.inputSchema && !match.tool.resourceUri) {
|
||||
text += `\n Parameters:\n${formatSchema(match.tool.inputSchema, " ")}\n`;
|
||||
} else if (match.tool.resourceUri) {
|
||||
text += ` No parameters (resource tool).\n`;
|
||||
}
|
||||
text += "\n";
|
||||
} else {
|
||||
text += `- ${match.tool.name}`;
|
||||
if (match.tool.description) {
|
||||
text += ` - ${truncateAtWord(match.tool.description, 50)}`;
|
||||
}
|
||||
text += "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: text.trim() }],
|
||||
details: {
|
||||
mode: "search",
|
||||
matches: matches.map(m => ({ server: m.server, tool: m.tool.name })),
|
||||
count: totalCount,
|
||||
query,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function executeList(state: McpExtensionState, server: string): ProxyToolResult {
|
||||
if (!state.config.mcpServers[server]) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({}) to see available servers.` }],
|
||||
details: { mode: "list", server, tools: [], count: 0, error: "not_found" },
|
||||
};
|
||||
}
|
||||
|
||||
const metadata = state.toolMetadata.get(server);
|
||||
const toolNames = metadata?.map(m => m.name) ?? [];
|
||||
const connection = state.manager.getConnection(server);
|
||||
|
||||
if (toolNames.length === 0) {
|
||||
if (connection?.status === "connected") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${server}" has no tools.` }],
|
||||
details: { mode: "list", server, tools: [], count: 0 },
|
||||
};
|
||||
}
|
||||
if (metadata !== undefined) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${server}" has no cached tools (not connected).` }],
|
||||
details: { mode: "list", server, tools: [], count: 0, cached: true },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${server}" is configured but not connected. Use mcp({ connect: "${server}" }) or /mcp reconnect ${server} to retry.` }],
|
||||
details: { mode: "list", server, tools: [], count: 0, error: "not_connected" },
|
||||
};
|
||||
}
|
||||
|
||||
const cachedNote = connection?.status === "connected" ? "" : " (not connected, cached)";
|
||||
let text = `${server} (${toolNames.length} tools${cachedNote}):\n\n`;
|
||||
|
||||
const descMap = new Map<string, string>();
|
||||
if (metadata) {
|
||||
for (const m of metadata) {
|
||||
descMap.set(m.name, m.description);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tool of toolNames) {
|
||||
const desc = descMap.get(tool) ?? "";
|
||||
const truncated = truncateAtWord(desc, 50);
|
||||
text += `- ${tool}`;
|
||||
if (truncated) text += ` - ${truncated}`;
|
||||
text += "\n";
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: text.trim() }],
|
||||
details: { mode: "list", server, tools: toolNames, count: toolNames.length },
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeConnect(state: McpExtensionState, serverName: string): Promise<ProxyToolResult> {
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
|
||||
details: { mode: "connect", error: "not_found", server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (state.ui) {
|
||||
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
||||
}
|
||||
let connection = await state.manager.connect(serverName, definition);
|
||||
if (connection.status === "needs-auth") {
|
||||
const autoAuth = await attemptAutoAuth(state, serverName);
|
||||
if (autoAuth.status === "failed") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: autoAuth.message }],
|
||||
details: { mode: "connect", error: "auth_required", server: serverName, message: autoAuth.message },
|
||||
};
|
||||
}
|
||||
if (autoAuth.status === "success") {
|
||||
await state.manager.close(serverName);
|
||||
connection = await state.manager.connect(serverName, definition);
|
||||
}
|
||||
if (connection.status === "needs-auth") {
|
||||
const message = getAuthRequiredMessage(state, serverName);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { mode: "connect", error: "auth_required", server: serverName, message },
|
||||
};
|
||||
}
|
||||
}
|
||||
const prefix = state.config.settings?.toolPrefix ?? "server";
|
||||
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
|
||||
state.toolMetadata.set(serverName, metadata);
|
||||
updateMetadataCache(state, serverName);
|
||||
state.failureTracker.delete(serverName);
|
||||
updateStatusBar(state);
|
||||
return executeList(state, serverName);
|
||||
} catch (error) {
|
||||
state.failureTracker.set(serverName, Date.now());
|
||||
updateStatusBar(state);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Failed to connect to "${serverName}": ${message}` }],
|
||||
details: { mode: "connect", error: "connect_failed", server: serverName, message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCall(
|
||||
state: McpExtensionState,
|
||||
toolName: string,
|
||||
args?: Record<string, unknown>,
|
||||
serverOverride?: string,
|
||||
getPiTools?: () => ToolInfo[],
|
||||
): Promise<ProxyToolResult> {
|
||||
let serverName: string | undefined = serverOverride;
|
||||
let toolMeta: ToolMetadata | undefined;
|
||||
let autoAuthAttempted = false;
|
||||
const prefixMode = state.config.settings?.toolPrefix ?? "server";
|
||||
|
||||
if (serverName && !state.config.mcpServers[serverName]) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
|
||||
details: { mode: "call", error: "server_not_found", server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
if (serverName) {
|
||||
toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
|
||||
} else {
|
||||
for (const [server, metadata] of state.toolMetadata.entries()) {
|
||||
const found = findToolByName(metadata, toolName);
|
||||
if (found) {
|
||||
serverName = server;
|
||||
toolMeta = found;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (serverName && !toolMeta) {
|
||||
const connected = await lazyConnect(state, serverName);
|
||||
if (connected) {
|
||||
toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
|
||||
} else {
|
||||
const needsAuthConnection = state.manager.getConnection(serverName);
|
||||
if (needsAuthConnection?.status === "needs-auth") {
|
||||
if (!autoAuthAttempted) {
|
||||
autoAuthAttempted = true;
|
||||
const autoAuth = await attemptAutoAuth(state, serverName);
|
||||
if (autoAuth.status === "failed") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: autoAuth.message }],
|
||||
details: { mode: "call", error: "auth_required", server: serverName, message: autoAuth.message },
|
||||
};
|
||||
}
|
||||
if (autoAuth.status === "success") {
|
||||
await state.manager.close(serverName);
|
||||
state.failureTracker.delete(serverName);
|
||||
const connectedAfterAuth = await lazyConnect(state, serverName);
|
||||
if (connectedAfterAuth) {
|
||||
toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
|
||||
if (!toolMeta) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Tool "${toolName}" not found on "${serverName}" after reconnect.` }],
|
||||
details: { mode: "call", error: "tool_not_found_after_reconnect", requestedTool: toolName },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!toolMeta && state.manager.getConnection(serverName)?.status === "needs-auth") {
|
||||
const message = getAuthRequiredMessage(state, serverName);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { mode: "call", error: "auth_required", server: serverName, message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!toolMeta) {
|
||||
const failedAgo = getFailureAgeSeconds(state, serverName);
|
||||
if (failedAgo !== null) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
|
||||
details: { mode: "call", error: "server_backoff", server: serverName },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let prefixMatchedServer: string | undefined;
|
||||
|
||||
if (!serverName && !toolMeta && prefixMode !== "none") {
|
||||
const candidates = Object.keys(state.config.mcpServers)
|
||||
.map(name => ({ name, prefix: getServerPrefix(name, prefixMode) }))
|
||||
.filter(c => c.prefix && toolName.startsWith(c.prefix + "_"))
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||
|
||||
for (const { name: configuredServer } of candidates) {
|
||||
const existingConnection = state.manager.getConnection(configuredServer);
|
||||
const failedAgo = getFailureAgeSeconds(state, configuredServer);
|
||||
if (failedAgo !== null && existingConnection?.status !== "needs-auth") continue;
|
||||
|
||||
let connected = await lazyConnect(state, configuredServer);
|
||||
if (!connected && state.manager.getConnection(configuredServer)?.status === "needs-auth" && !autoAuthAttempted) {
|
||||
autoAuthAttempted = true;
|
||||
const autoAuth = await attemptAutoAuth(state, configuredServer);
|
||||
if (autoAuth.status === "failed") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: autoAuth.message }],
|
||||
details: { mode: "call", error: "auth_required", server: configuredServer, message: autoAuth.message },
|
||||
};
|
||||
}
|
||||
if (autoAuth.status === "success") {
|
||||
await state.manager.close(configuredServer);
|
||||
state.failureTracker.delete(configuredServer);
|
||||
connected = await lazyConnect(state, configuredServer);
|
||||
}
|
||||
}
|
||||
|
||||
if (!connected) continue;
|
||||
if (!prefixMatchedServer) prefixMatchedServer = configuredServer;
|
||||
toolMeta = findToolByName(state.toolMetadata.get(configuredServer), toolName);
|
||||
if (toolMeta) {
|
||||
serverName = configuredServer;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverName || !toolMeta) {
|
||||
const nativeTool = !serverOverride
|
||||
? getPiTools?.().find((tool) => tool.name === toolName && tool.name !== "mcp")
|
||||
: undefined;
|
||||
if (nativeTool) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `"${toolName}" is a native Pi tool. Call ${toolName} directly instead of using mcp({ tool: "${toolName}" }).` }],
|
||||
details: { mode: "call", error: "native_tool", requestedTool: toolName },
|
||||
};
|
||||
}
|
||||
|
||||
const hintServer = serverName ?? prefixMatchedServer;
|
||||
const available = hintServer ? getToolNames(state, hintServer) : [];
|
||||
let msg = `Tool "${toolName}" not found.`;
|
||||
if (available.length > 0) {
|
||||
msg += ` Server "${hintServer}" has: ${available.join(", ")}`;
|
||||
} else {
|
||||
msg += ` Use mcp({ search: "..." }) to search.`;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: msg }],
|
||||
details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer },
|
||||
};
|
||||
}
|
||||
|
||||
let connection = state.manager.getConnection(serverName);
|
||||
if (connection?.status === "needs-auth") {
|
||||
if (!autoAuthAttempted) {
|
||||
autoAuthAttempted = true;
|
||||
const autoAuth = await attemptAutoAuth(state, serverName);
|
||||
if (autoAuth.status === "failed") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: autoAuth.message }],
|
||||
details: { mode: "call", error: "auth_required", server: serverName, message: autoAuth.message },
|
||||
};
|
||||
}
|
||||
if (autoAuth.status === "success") {
|
||||
await state.manager.close(serverName);
|
||||
state.failureTracker.delete(serverName);
|
||||
connection = state.manager.getConnection(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
if (connection?.status === "needs-auth") {
|
||||
const message = getAuthRequiredMessage(state, serverName);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { mode: "call", error: "auth_required", server: serverName, message },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!connection || connection.status !== "connected") {
|
||||
const failedAgo = getFailureAgeSeconds(state, serverName);
|
||||
if (failedAgo !== null) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
|
||||
details: { mode: "call", error: "server_backoff", server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
const definition = state.config.mcpServers[serverName];
|
||||
if (!definition) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Server "${serverName}" not connected` }],
|
||||
details: { mode: "call", error: "server_not_connected", server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (state.ui) {
|
||||
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
||||
}
|
||||
connection = await state.manager.connect(serverName, definition);
|
||||
if (connection.status === "needs-auth") {
|
||||
if (!autoAuthAttempted) {
|
||||
autoAuthAttempted = true;
|
||||
const autoAuth = await attemptAutoAuth(state, serverName);
|
||||
if (autoAuth.status === "failed") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: autoAuth.message }],
|
||||
details: { mode: "call", error: "auth_required", server: serverName, message: autoAuth.message },
|
||||
};
|
||||
}
|
||||
if (autoAuth.status === "success") {
|
||||
await state.manager.close(serverName);
|
||||
connection = await state.manager.connect(serverName, definition);
|
||||
}
|
||||
}
|
||||
|
||||
if (connection.status === "needs-auth") {
|
||||
const message = getAuthRequiredMessage(state, serverName);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { mode: "call", error: "auth_required", server: serverName, message },
|
||||
};
|
||||
}
|
||||
}
|
||||
state.failureTracker.delete(serverName);
|
||||
updateServerMetadata(state, serverName);
|
||||
updateMetadataCache(state, serverName);
|
||||
updateStatusBar(state);
|
||||
toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
|
||||
if (!toolMeta) {
|
||||
const available = getToolNames(state, serverName);
|
||||
const hint = available.length > 0
|
||||
? `Available tools on "${serverName}": ${available.join(", ")}`
|
||||
: `Server "${serverName}" has no tools.`;
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Tool "${toolName}" not found on "${serverName}" after reconnect. ${hint}` }],
|
||||
details: { mode: "call", error: "tool_not_found_after_reconnect", requestedTool: toolName },
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
state.failureTracker.set(serverName, Date.now());
|
||||
updateStatusBar(state);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Failed to connect to "${serverName}": ${message}` }],
|
||||
details: { mode: "call", error: "connect_failed", message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let uiSession: UiSessionRuntime | null = null;
|
||||
|
||||
try {
|
||||
state.manager.touch(serverName);
|
||||
state.manager.incrementInFlight(serverName);
|
||||
|
||||
if (toolMeta.resourceUri) {
|
||||
const result = await connection.client.readResource({ uri: toolMeta.resourceUri });
|
||||
const content = (result.contents ?? []).map(c => ({
|
||||
type: "text" as const,
|
||||
text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
|
||||
}));
|
||||
return {
|
||||
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }],
|
||||
details: { mode: "call", resourceUri: toolMeta.resourceUri, server: serverName },
|
||||
};
|
||||
}
|
||||
|
||||
uiSession = toolMeta.uiResourceUri
|
||||
? await maybeStartUiSession(state, {
|
||||
serverName,
|
||||
toolName: toolMeta.originalName,
|
||||
toolArgs: args ?? {},
|
||||
uiResourceUri: toolMeta.uiResourceUri,
|
||||
streamMode: toolMeta.uiStreamMode,
|
||||
})
|
||||
: null;
|
||||
|
||||
const resultPromise = connection.client.callTool({
|
||||
name: toolMeta.originalName,
|
||||
arguments: args ?? {},
|
||||
_meta: uiSession?.requestMeta,
|
||||
});
|
||||
|
||||
if (toolMeta.uiResourceUri) {
|
||||
const result = await resultPromise;
|
||||
uiSession?.sendToolResult(result as unknown as import("@modelcontextprotocol/sdk/types.js").CallToolResult);
|
||||
const mcpContent = (result.content ?? []) as McpContent[];
|
||||
const content = transformMcpContent(mcpContent);
|
||||
|
||||
const mcpText = content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => (c as { text: string }).text)
|
||||
.join("\n");
|
||||
|
||||
if (result.isError) {
|
||||
let errorWithSchema = `Error: ${mcpText || "Tool execution failed"}`;
|
||||
if (toolMeta.inputSchema) {
|
||||
errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: errorWithSchema }],
|
||||
details: { mode: "call", error: "tool_error", mcpResult: result },
|
||||
};
|
||||
}
|
||||
|
||||
const resultText = mcpText || "(empty result)";
|
||||
const uiMessage = uiSession?.reused
|
||||
? "Updated the open UI."
|
||||
: "📺 Interactive UI is now open in your browser. I'll respond to your prompts and intents as you interact with it.";
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `${resultText}\n\n${uiMessage}` }],
|
||||
details: { mode: "call", mcpResult: result, server: serverName, tool: toolMeta.originalName, uiOpen: true },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
const mcpContent = (result.content ?? []) as McpContent[];
|
||||
const content = transformMcpContent(mcpContent);
|
||||
|
||||
if (result.isError) {
|
||||
const errorText = content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => (c as { text: string }).text)
|
||||
.join("\n") || "Tool execution failed";
|
||||
|
||||
let errorWithSchema = `Error: ${errorText}`;
|
||||
if (toolMeta.inputSchema) {
|
||||
errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: errorWithSchema }],
|
||||
details: { mode: "call", error: "tool_error", mcpResult: result },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
|
||||
details: { mode: "call", mcpResult: result, server: serverName, tool: toolMeta.originalName },
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UrlElicitationRequiredError) {
|
||||
const action = await state.manager.handleUrlElicitationRequired(serverName, error);
|
||||
const message = action === "accept"
|
||||
? "The original MCP tool did not run. Complete the opened browser interaction, then retry the tool."
|
||||
: `The URL interaction was ${action === "decline" ? "declined" : "cancelled"}.`;
|
||||
uiSession?.sendToolCancelled(message);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: message }],
|
||||
details: { mode: "call", error: "url_elicitation_required", server: serverName, action },
|
||||
};
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
uiSession?.sendToolCancelled(message);
|
||||
|
||||
let errorWithSchema = `Failed to call tool: ${message}`;
|
||||
if (toolMeta.inputSchema) {
|
||||
errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: errorWithSchema }],
|
||||
details: { mode: "call", error: "call_failed", message },
|
||||
};
|
||||
} finally {
|
||||
if (uiSession?.reused) {
|
||||
uiSession.close();
|
||||
}
|
||||
state.manager.decrementInFlight(serverName);
|
||||
state.manager.touch(serverName);
|
||||
}
|
||||
}
|
||||
17
agent/extensions/pi-mcp-adapter/resource-tools.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// resource-tools.ts - MCP resource name utilities
|
||||
|
||||
export function resourceNameToToolName(name: string): string {
|
||||
let result = name
|
||||
.replace(/[^a-zA-Z0-9]/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_+/, "") // Remove leading underscores
|
||||
.replace(/_+$/, "") // Remove trailing underscores
|
||||
.toLowerCase();
|
||||
|
||||
// Ensure we have a valid name
|
||||
if (!result || /^\d/.test(result)) {
|
||||
result = "resource" + (result ? "_" + result : "");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
268
agent/extensions/pi-mcp-adapter/sampling-handler.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { complete, type Api, type AssistantMessage, type Message, type Model, type TextContent } from "@earendil-works/pi-ai";
|
||||
import { truncateAtWord } from "./utils.ts";
|
||||
import type { ExtensionUIContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import {
|
||||
CreateMessageRequestSchema,
|
||||
type CreateMessageRequest,
|
||||
type CreateMessageResult,
|
||||
type ModelPreferences,
|
||||
type SamplingMessage,
|
||||
type SamplingMessageContentBlock,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
export interface SamplingHandlerOptions {
|
||||
serverName: string;
|
||||
autoApprove: boolean;
|
||||
ui?: ExtensionUIContext;
|
||||
modelRegistry: ModelRegistry;
|
||||
getCurrentModel: () => Model<Api> | undefined;
|
||||
getSignal: () => AbortSignal | undefined;
|
||||
}
|
||||
|
||||
export type ServerSamplingConfig = Omit<SamplingHandlerOptions, "serverName">;
|
||||
|
||||
export function registerSamplingHandler(client: Client, options: SamplingHandlerOptions): void {
|
||||
client.setRequestHandler(CreateMessageRequestSchema, (request) => {
|
||||
return handleSamplingRequest(options, request as CreateMessageRequest);
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleSamplingRequest(
|
||||
options: SamplingHandlerOptions,
|
||||
request: CreateMessageRequest,
|
||||
): Promise<CreateMessageResult> {
|
||||
const params = request.params;
|
||||
|
||||
if ("task" in params && params.task) {
|
||||
throw new Error("MCP sampling tasks are not supported");
|
||||
}
|
||||
if (params.includeContext && params.includeContext !== "none") {
|
||||
throw new Error("MCP sampling context inclusion is not supported");
|
||||
}
|
||||
if (params.tools?.length) {
|
||||
throw new Error("MCP sampling tool use is not supported");
|
||||
}
|
||||
if (params.toolChoice) {
|
||||
throw new Error("MCP sampling tool choice is not supported");
|
||||
}
|
||||
if (params.stopSequences?.length) {
|
||||
throw new Error("MCP sampling stop sequences are not supported");
|
||||
}
|
||||
|
||||
const messages = params.messages.map(convertSamplingMessage);
|
||||
const { model, apiKey, headers } = await resolveSamplingModel(options, params.modelPreferences);
|
||||
await confirmSampling(
|
||||
options,
|
||||
"Approve MCP sampling request",
|
||||
formatRequestApproval(options.serverName, `${model.provider}/${model.id}`, params.systemPrompt, messages),
|
||||
);
|
||||
|
||||
const result = await complete(
|
||||
model,
|
||||
{
|
||||
systemPrompt: params.systemPrompt,
|
||||
messages,
|
||||
},
|
||||
{
|
||||
apiKey,
|
||||
headers,
|
||||
maxTokens: params.maxTokens,
|
||||
temperature: params.temperature,
|
||||
metadata: params.metadata as Record<string, unknown> | undefined,
|
||||
signal: options.getSignal(),
|
||||
},
|
||||
);
|
||||
|
||||
const converted = convertAssistantResult(result);
|
||||
await confirmSampling(
|
||||
options,
|
||||
"Return MCP sampling response",
|
||||
formatResponseApproval(options.serverName, converted),
|
||||
);
|
||||
return converted;
|
||||
}
|
||||
|
||||
function formatRequestApproval(
|
||||
serverName: string,
|
||||
modelName: string,
|
||||
systemPrompt: string | undefined,
|
||||
messages: Message[],
|
||||
): string {
|
||||
const lines = [`${serverName} wants to sample ${messages.length} message${messages.length === 1 ? "" : "s"} with ${modelName}.`];
|
||||
if (systemPrompt) {
|
||||
lines.push(`System: ${truncateAtWord(systemPrompt, 400)}`);
|
||||
}
|
||||
for (const [index, message] of messages.entries()) {
|
||||
lines.push(`${index + 1}. ${message.role}: ${truncateAtWord(messageText(message), 400)}`);
|
||||
}
|
||||
return lines.join("\n\n");
|
||||
}
|
||||
|
||||
function formatResponseApproval(serverName: string, response: CreateMessageResult): string {
|
||||
const text = response.content.type === "text" ? response.content.text : `[${response.content.type} content]`;
|
||||
return `${serverName} will receive this response from ${response.model}:\n\n${truncateAtWord(text, 1000)}`;
|
||||
}
|
||||
|
||||
function messageText(message: Message): string {
|
||||
if (typeof message.content === "string") return message.content;
|
||||
return message.content.map((block) => {
|
||||
if (block.type === "text") return block.text;
|
||||
if (block.type === "image") return `[image: ${block.mimeType}]`;
|
||||
if (block.type === "thinking") return "[thinking]";
|
||||
if (block.type === "toolCall") return `[tool call: ${block.name}]`;
|
||||
return "[content]";
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
async function resolveSamplingModel(
|
||||
options: SamplingHandlerOptions,
|
||||
modelPreferences: ModelPreferences | undefined,
|
||||
): Promise<{
|
||||
model: Model<Api>;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
}> {
|
||||
const candidates: Model<Api>[] = [];
|
||||
const availableModels = options.modelRegistry.getAvailable();
|
||||
|
||||
for (const hint of modelPreferences?.hints ?? []) {
|
||||
const normalizedHint = hint.name?.trim().toLowerCase();
|
||||
if (!normalizedHint) continue;
|
||||
for (const model of availableModels) {
|
||||
const searchableNames = [`${model.provider}/${model.id}`, model.id, model.name];
|
||||
if (searchableNames.some((name) => name.toLowerCase().includes(normalizedHint))) {
|
||||
addSamplingCandidate(candidates, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentModel = options.getCurrentModel();
|
||||
if (currentModel) addSamplingCandidate(candidates, currentModel);
|
||||
|
||||
for (const model of availableModels) {
|
||||
addSamplingCandidate(candidates, model);
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
for (const model of candidates) {
|
||||
const auth = await options.modelRegistry.getApiKeyAndHeaders(model);
|
||||
if (auth.ok === false) {
|
||||
errors.push(`${model.provider}/${model.id}: ${auth.error}`);
|
||||
continue;
|
||||
}
|
||||
return { model, apiKey: auth.apiKey, headers: auth.headers };
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`No configured auth for MCP sampling model. ${errors.join("; ")}`);
|
||||
}
|
||||
throw new Error("No Pi model is available for MCP sampling");
|
||||
}
|
||||
|
||||
function addSamplingCandidate(candidates: Model<Api>[], model: Model<Api>): void {
|
||||
if (!candidates.some((candidate) => candidate.provider === model.provider && candidate.id === model.id)) {
|
||||
candidates.push(model);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSampling(options: SamplingHandlerOptions, title: string, message: string): Promise<void> {
|
||||
if (options.autoApprove) return;
|
||||
if (!options.ui) {
|
||||
throw new Error("MCP sampling requires interactive approval. Set settings.samplingAutoApprove to true to allow it without UI.");
|
||||
}
|
||||
const approved = await options.ui.confirm(title, message);
|
||||
if (!approved) {
|
||||
throw new Error("MCP sampling request was declined");
|
||||
}
|
||||
}
|
||||
|
||||
function convertSamplingMessage(message: SamplingMessage): Message {
|
||||
const blocks = Array.isArray(message.content) ? message.content : [message.content];
|
||||
if (message.role === "user") {
|
||||
return {
|
||||
role: "user",
|
||||
content: blocks.map(convertUserContent),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
content: blocks.map(convertAssistantContent),
|
||||
api: "mcp-sampling",
|
||||
provider: "mcp",
|
||||
model: "sampling-request",
|
||||
usage: zeroUsage(),
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function convertUserContent(block: SamplingMessageContentBlock): TextContent {
|
||||
if (block.type === "text") {
|
||||
return { type: "text", text: block.text };
|
||||
}
|
||||
throw new Error(`MCP sampling ${block.type} content is not supported`);
|
||||
}
|
||||
|
||||
function convertAssistantContent(block: SamplingMessageContentBlock): TextContent {
|
||||
if (block.type === "text") {
|
||||
return { type: "text", text: block.text };
|
||||
}
|
||||
throw new Error(`MCP sampling assistant ${block.type} content is not supported`);
|
||||
}
|
||||
|
||||
function convertAssistantResult(message: AssistantMessage): CreateMessageResult {
|
||||
if (message.stopReason === "error") {
|
||||
throw new Error(message.errorMessage ?? "MCP sampling model call failed");
|
||||
}
|
||||
if (message.stopReason === "aborted") {
|
||||
throw new Error(message.errorMessage ?? "MCP sampling model call was aborted");
|
||||
}
|
||||
|
||||
const text = message.content
|
||||
.map((block) => {
|
||||
if (block.type === "text") return block.text;
|
||||
if (block.type === "thinking") return undefined;
|
||||
throw new Error(`MCP sampling result ${block.type} content is not supported`);
|
||||
})
|
||||
.filter((value): value is string => value !== undefined)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
|
||||
if (!text) {
|
||||
throw new Error("MCP sampling result did not contain text content");
|
||||
}
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
content: { type: "text", text },
|
||||
model: `${message.provider}/${message.model}`,
|
||||
stopReason: mapStopReason(message.stopReason),
|
||||
};
|
||||
}
|
||||
|
||||
function mapStopReason(reason: AssistantMessage["stopReason"]): CreateMessageResult["stopReason"] {
|
||||
if (reason === "stop") return "endTurn";
|
||||
if (reason === "length") return "maxTokens";
|
||||
if (reason === "toolUse") return "toolUse";
|
||||
return reason;
|
||||
}
|
||||
|
||||
function zeroUsage(): AssistantMessage["usage"] {
|
||||
return {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
442
agent/extensions/pi-mcp-adapter/server-manager.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import {
|
||||
ElicitationCompleteNotificationSchema,
|
||||
type ReadResourceResult,
|
||||
type UrlElicitationRequiredError,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type {
|
||||
McpTool,
|
||||
McpResource,
|
||||
ServerDefinition,
|
||||
ServerStreamResultPatchNotification,
|
||||
Transport,
|
||||
} from "./types.ts";
|
||||
import { serverStreamResultPatchNotificationSchema } from "./types.ts";
|
||||
import { resolveNpxBinary } from "./npx-resolver.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { McpOAuthProvider } from "./mcp-oauth-provider.ts";
|
||||
import { extractOAuthConfig, supportsOAuth } from "./mcp-auth-flow.ts";
|
||||
import { registerSamplingHandler, type ServerSamplingConfig } from "./sampling-handler.ts";
|
||||
import {
|
||||
handleUrlElicitation,
|
||||
registerElicitationHandler,
|
||||
type ServerElicitationConfig,
|
||||
} from "./elicitation-handler.ts";
|
||||
import { interpolateEnvRecord, resolveBearerToken, resolveConfigPath } from "./utils.ts";
|
||||
|
||||
interface ServerConnection {
|
||||
client: Client;
|
||||
transport: Transport;
|
||||
definition: ServerDefinition;
|
||||
tools: McpTool[];
|
||||
resources: McpResource[];
|
||||
lastUsedAt: number;
|
||||
inFlight: number;
|
||||
status: "connected" | "closed" | "needs-auth";
|
||||
}
|
||||
|
||||
type UiStreamListener = (serverName: string, notification: ServerStreamResultPatchNotification["params"]) => void;
|
||||
|
||||
export class McpServerManager {
|
||||
private connections = new Map<string, ServerConnection>();
|
||||
private connectPromises = new Map<string, Promise<ServerConnection>>();
|
||||
private uiStreamListeners = new Map<string, UiStreamListener>();
|
||||
private samplingConfig: ServerSamplingConfig | undefined;
|
||||
private elicitationConfig: ServerElicitationConfig | undefined;
|
||||
private acceptedUrlElicitations = new Map<string, Set<string>>();
|
||||
|
||||
setSamplingConfig(config: ServerSamplingConfig | undefined): void {
|
||||
this.samplingConfig = config;
|
||||
}
|
||||
|
||||
setElicitationConfig(config: ServerElicitationConfig | undefined): void {
|
||||
this.elicitationConfig = config;
|
||||
}
|
||||
|
||||
async connect(name: string, definition: ServerDefinition): Promise<ServerConnection> {
|
||||
// Dedupe concurrent connection attempts
|
||||
if (this.connectPromises.has(name)) {
|
||||
return this.connectPromises.get(name)!;
|
||||
}
|
||||
|
||||
// Reuse existing connection if healthy
|
||||
const existing = this.connections.get(name);
|
||||
if (existing?.status === "connected") {
|
||||
existing.lastUsedAt = Date.now();
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = this.createConnection(name, definition);
|
||||
this.connectPromises.set(name, promise);
|
||||
|
||||
try {
|
||||
const connection = await promise;
|
||||
this.connections.set(name, connection);
|
||||
return connection;
|
||||
} finally {
|
||||
this.connectPromises.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
private async createConnection(
|
||||
name: string,
|
||||
definition: ServerDefinition
|
||||
): Promise<ServerConnection> {
|
||||
const client = this.createClient(name);
|
||||
|
||||
let transport: Transport;
|
||||
|
||||
if (definition.command) {
|
||||
let command = definition.command;
|
||||
let args = definition.args ?? [];
|
||||
|
||||
if (command === "npx" || command === "npm") {
|
||||
const resolved = await resolveNpxBinary(command, args);
|
||||
if (resolved) {
|
||||
command = resolved.isJs ? "node" : resolved.binPath;
|
||||
args = resolved.isJs ? [resolved.binPath, ...resolved.extraArgs] : resolved.extraArgs;
|
||||
logger.debug(`${name} resolved to ${resolved.binPath} (skipping npm parent)`);
|
||||
}
|
||||
}
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command,
|
||||
args,
|
||||
env: resolveEnv(definition.env),
|
||||
cwd: resolveConfigPath(definition.cwd),
|
||||
stderr: definition.debug ? "inherit" : "ignore",
|
||||
});
|
||||
} else if (definition.url) {
|
||||
// HTTP transport with fallback
|
||||
transport = await this.createHttpTransport(definition, name);
|
||||
} else {
|
||||
throw new Error(`Server ${name} has no command or url`);
|
||||
}
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
this.attachAdapterNotificationHandlers(name, client);
|
||||
|
||||
// Discover tools and resources
|
||||
const [tools, resources] = await Promise.all([
|
||||
this.fetchAllTools(client),
|
||||
this.fetchAllResources(client),
|
||||
]);
|
||||
|
||||
return {
|
||||
client,
|
||||
transport,
|
||||
definition,
|
||||
tools,
|
||||
resources,
|
||||
lastUsedAt: Date.now(),
|
||||
inFlight: 0,
|
||||
status: "connected",
|
||||
};
|
||||
} catch (error) {
|
||||
// Check for UnauthorizedError - server requires OAuth
|
||||
if (error instanceof UnauthorizedError && supportsOAuth(definition)) {
|
||||
// Clean up both client and transport before reporting needs-auth.
|
||||
await client.close().catch(() => {});
|
||||
await transport.close().catch(() => {});
|
||||
|
||||
return {
|
||||
client,
|
||||
transport,
|
||||
definition,
|
||||
tools: [],
|
||||
resources: [],
|
||||
lastUsedAt: Date.now(),
|
||||
inFlight: 0,
|
||||
status: "needs-auth",
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up both client and transport on any error
|
||||
await client.close().catch(() => {});
|
||||
await transport.close().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private buildClientCapabilities() {
|
||||
return {
|
||||
...(this.samplingConfig ? { sampling: {} } : {}),
|
||||
...(this.elicitationConfig
|
||||
? {
|
||||
elicitation: {
|
||||
form: {},
|
||||
...(this.elicitationConfig.allowUrl ? { url: {} } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private createClient(serverName: string): Client {
|
||||
const capabilities = this.buildClientCapabilities();
|
||||
const client = new Client(
|
||||
{ name: `pi-mcp-${serverName}`, version: "1.0.0" },
|
||||
Object.keys(capabilities).length > 0 ? { capabilities } : undefined,
|
||||
);
|
||||
if (this.samplingConfig) {
|
||||
registerSamplingHandler(client, { ...this.samplingConfig, serverName });
|
||||
}
|
||||
if (this.elicitationConfig) {
|
||||
registerElicitationHandler(client, {
|
||||
...this.elicitationConfig,
|
||||
serverName,
|
||||
onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId),
|
||||
});
|
||||
if (this.elicitationConfig.allowUrl) {
|
||||
client.setNotificationHandler(ElicitationCompleteNotificationSchema, notification => {
|
||||
const accepted = this.acceptedUrlElicitations.get(serverName);
|
||||
if (!accepted?.delete(notification.params.elicitationId)) return;
|
||||
this.elicitationConfig?.ui.notify(
|
||||
`MCP browser interaction for ${serverName} completed. You can retry the tool now.`,
|
||||
"info",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
async handleUrlElicitationRequired(
|
||||
serverName: string,
|
||||
error: UrlElicitationRequiredError,
|
||||
): Promise<"accept" | "decline" | "cancel"> {
|
||||
if (!this.elicitationConfig?.allowUrl) return "cancel";
|
||||
for (const params of error.elicitations) {
|
||||
const result = await handleUrlElicitation({
|
||||
...this.elicitationConfig,
|
||||
serverName,
|
||||
onUrlAccepted: elicitationId => this.rememberUrlElicitation(serverName, elicitationId),
|
||||
}, params);
|
||||
if (result.action !== "accept") return result.action;
|
||||
}
|
||||
return "accept";
|
||||
}
|
||||
|
||||
private rememberUrlElicitation(serverName: string, elicitationId: string): void {
|
||||
let accepted = this.acceptedUrlElicitations.get(serverName);
|
||||
if (!accepted) {
|
||||
accepted = new Set();
|
||||
this.acceptedUrlElicitations.set(serverName, accepted);
|
||||
}
|
||||
accepted.add(elicitationId);
|
||||
}
|
||||
|
||||
private async createHttpTransport(
|
||||
definition: ServerDefinition,
|
||||
serverName: string
|
||||
): Promise<Transport> {
|
||||
const url = new URL(definition.url!);
|
||||
|
||||
// Build headers first (including any bearer token)
|
||||
const headers = resolveHeaders(definition.headers) ?? {};
|
||||
|
||||
// For bearer auth, add the token to headers BEFORE creating requestInit
|
||||
if (definition.auth === "bearer") {
|
||||
const token = resolveBearerToken(definition);
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create request init with headers (Authorization now included for bearer auth)
|
||||
const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined;
|
||||
|
||||
// For OAuth servers, create an auth provider
|
||||
let authProvider: McpOAuthProvider | undefined;
|
||||
if (supportsOAuth(definition)) {
|
||||
const oauthConfig = extractOAuthConfig(definition);
|
||||
authProvider = new McpOAuthProvider(
|
||||
serverName,
|
||||
definition.url!,
|
||||
oauthConfig,
|
||||
{
|
||||
onRedirect: async (_authUrl) => {
|
||||
// URL is captured by startAuth, no need to log
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Try StreamableHTTP first (modern MCP servers)
|
||||
const streamableTransport = new StreamableHTTPClientTransport(url, {
|
||||
requestInit,
|
||||
authProvider,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create a test client to verify the transport works
|
||||
const testClient = new Client({ name: "pi-mcp-probe", version: "2.1.2" });
|
||||
await testClient.connect(streamableTransport);
|
||||
await testClient.close().catch(() => {});
|
||||
// Close probe transport before creating fresh one
|
||||
await streamableTransport.close().catch(() => {});
|
||||
|
||||
// StreamableHTTP works - create fresh transport for actual use
|
||||
return new StreamableHTTPClientTransport(url, { requestInit, authProvider });
|
||||
} catch (error) {
|
||||
// StreamableHTTP failed, close and try SSE fallback
|
||||
await streamableTransport.close().catch(() => {});
|
||||
|
||||
// If this was an UnauthorizedError, don't try SSE - the server needs auth
|
||||
if (error instanceof UnauthorizedError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// SSE is the legacy transport
|
||||
return new SSEClientTransport(url, { requestInit, authProvider });
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAllTools(client: Client): Promise<McpTool[]> {
|
||||
const allTools: McpTool[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const result = await client.listTools(cursor ? { cursor } : undefined);
|
||||
allTools.push(...(result.tools ?? []));
|
||||
cursor = result.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
return allTools;
|
||||
}
|
||||
|
||||
private async fetchAllResources(client: Client): Promise<McpResource[]> {
|
||||
try {
|
||||
const allResources: McpResource[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const result = await client.listResources(cursor ? { cursor } : undefined);
|
||||
allResources.push(...(result.resources ?? []));
|
||||
cursor = result.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
return allResources;
|
||||
} catch {
|
||||
// Server may not support resources
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private attachAdapterNotificationHandlers(serverName: string, client: Client): void {
|
||||
client.setNotificationHandler(serverStreamResultPatchNotificationSchema, (notification) => {
|
||||
const listener = this.uiStreamListeners.get(notification.params.streamToken);
|
||||
if (!listener) return;
|
||||
listener(serverName, notification.params);
|
||||
});
|
||||
}
|
||||
|
||||
registerUiStreamListener(streamToken: string, listener: UiStreamListener): void {
|
||||
this.uiStreamListeners.set(streamToken, listener);
|
||||
}
|
||||
|
||||
removeUiStreamListener(streamToken: string): void {
|
||||
this.uiStreamListeners.delete(streamToken);
|
||||
}
|
||||
|
||||
async readResource(name: string, uri: string): Promise<ReadResourceResult> {
|
||||
const connection = this.connections.get(name);
|
||||
if (!connection || connection.status !== "connected") {
|
||||
throw new Error(`Server "${name}" is not connected`);
|
||||
}
|
||||
|
||||
try {
|
||||
this.touch(name);
|
||||
this.incrementInFlight(name);
|
||||
return await connection.client.readResource({ uri });
|
||||
} finally {
|
||||
this.decrementInFlight(name);
|
||||
this.touch(name);
|
||||
}
|
||||
}
|
||||
|
||||
async close(name: string): Promise<void> {
|
||||
const connection = this.connections.get(name);
|
||||
if (!connection) return;
|
||||
|
||||
// Delete from map BEFORE async cleanup to prevent a race where a
|
||||
// concurrent connect() creates a new connection that our deferred
|
||||
// delete() would then remove, orphaning the new server process.
|
||||
connection.status = "closed";
|
||||
this.connections.delete(name);
|
||||
this.acceptedUrlElicitations.delete(name);
|
||||
await connection.client.close().catch(() => {});
|
||||
await connection.transport.close().catch(() => {});
|
||||
}
|
||||
|
||||
async closeAll(): Promise<void> {
|
||||
const names = [...this.connections.keys()];
|
||||
await Promise.all(names.map(name => this.close(name)));
|
||||
}
|
||||
|
||||
getConnection(name: string): ServerConnection | undefined {
|
||||
return this.connections.get(name);
|
||||
}
|
||||
|
||||
getAllConnections(): Map<string, ServerConnection> {
|
||||
return new Map(this.connections);
|
||||
}
|
||||
|
||||
touch(name: string): void {
|
||||
const connection = this.connections.get(name);
|
||||
if (connection) {
|
||||
connection.lastUsedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
incrementInFlight(name: string): void {
|
||||
const connection = this.connections.get(name);
|
||||
if (connection) {
|
||||
connection.inFlight = (connection.inFlight ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
decrementInFlight(name: string): void {
|
||||
const connection = this.connections.get(name);
|
||||
if (connection && connection.inFlight) {
|
||||
connection.inFlight--;
|
||||
}
|
||||
}
|
||||
|
||||
isIdle(name: string, timeoutMs: number): boolean {
|
||||
const connection = this.connections.get(name);
|
||||
if (!connection || connection.status !== "connected") return false;
|
||||
if (connection.inFlight > 0) return false;
|
||||
return (Date.now() - connection.lastUsedAt) > timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve environment variables with interpolation.
|
||||
*/
|
||||
function resolveEnv(env?: Record<string, string>): Record<string, string> {
|
||||
// Copy process.env, filtering out undefined values
|
||||
const resolved: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) {
|
||||
resolved[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!env) return resolved;
|
||||
|
||||
const overrides = interpolateEnvRecord(env);
|
||||
return overrides ? { ...resolved, ...overrides } : resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve headers with environment variable interpolation.
|
||||
*/
|
||||
function resolveHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
|
||||
return interpolateEnvRecord(headers);
|
||||
}
|
||||
41
agent/extensions/pi-mcp-adapter/state.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { ConsentManager } from "./consent-manager.ts";
|
||||
import type { McpLifecycleManager } from "./lifecycle.ts";
|
||||
import type { McpServerManager } from "./server-manager.ts";
|
||||
import type { ToolMetadata, McpConfig, UiSessionMessages, UiStreamSummary } from "./types.ts";
|
||||
import type { UiResourceHandler } from "./ui-resource-handler.ts";
|
||||
import type { UiServerHandle } from "./ui-server.ts";
|
||||
|
||||
export interface CompletedUiSession {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
completedAt: Date;
|
||||
reason: string;
|
||||
messages: UiSessionMessages;
|
||||
stream?: UiStreamSummary;
|
||||
}
|
||||
|
||||
export type SendMessageFn = (
|
||||
message: {
|
||||
customType: string;
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
display?: string;
|
||||
details?: unknown;
|
||||
},
|
||||
options?: { triggerTurn?: boolean }
|
||||
) => void;
|
||||
|
||||
export interface McpExtensionState {
|
||||
manager: McpServerManager;
|
||||
lifecycle: McpLifecycleManager;
|
||||
toolMetadata: Map<string, ToolMetadata[]>;
|
||||
config: McpConfig;
|
||||
failureTracker: Map<string, number>;
|
||||
uiResourceHandler: UiResourceHandler;
|
||||
consentManager: ConsentManager;
|
||||
uiServer: UiServerHandle | null;
|
||||
completedUiSessions: CompletedUiSession[];
|
||||
openBrowser: (url: string) => Promise<void>;
|
||||
ui?: ExtensionContext["ui"];
|
||||
sendMessage?: SendMessageFn;
|
||||
}
|
||||
216
agent/extensions/pi-mcp-adapter/tool-metadata.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import type { ToolMetadata, McpTool, McpResource, ServerEntry } from "./types.ts";
|
||||
import { formatToolName, isToolExcluded } from "./types.ts";
|
||||
import { resourceNameToToolName } from "./resource-tools.ts";
|
||||
import { extractToolUiStreamMode } from "./utils.ts";
|
||||
|
||||
export function buildToolMetadata(
|
||||
tools: McpTool[],
|
||||
resources: McpResource[],
|
||||
definition: ServerEntry,
|
||||
serverName: string,
|
||||
prefix: "server" | "none" | "short"
|
||||
): { metadata: ToolMetadata[]; failedTools: string[] } {
|
||||
const metadata: ToolMetadata[] = [];
|
||||
const failedTools: string[] = [];
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!tool?.name) {
|
||||
failedTools.push("(unnamed)");
|
||||
continue;
|
||||
}
|
||||
if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let uiResourceUri: string | undefined;
|
||||
try {
|
||||
uiResourceUri = getToolUiResourceUri({ _meta: tool._meta });
|
||||
} catch {
|
||||
failedTools.push(tool.name);
|
||||
}
|
||||
metadata.push({
|
||||
name: formatToolName(tool.name, serverName, prefix),
|
||||
originalName: tool.name,
|
||||
description: tool.description ?? "",
|
||||
inputSchema: tool.inputSchema,
|
||||
uiResourceUri,
|
||||
uiStreamMode: extractToolUiStreamMode(tool._meta),
|
||||
});
|
||||
}
|
||||
|
||||
if (definition.exposeResources !== false) {
|
||||
for (const resource of resources) {
|
||||
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
||||
if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata.push({
|
||||
name: formatToolName(baseName, serverName, prefix),
|
||||
originalName: baseName,
|
||||
description: resource.description ?? `Read resource: ${resource.uri}`,
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { metadata, failedTools };
|
||||
}
|
||||
|
||||
export function getToolNames(state: McpExtensionState, serverName: string): string[] {
|
||||
return state.toolMetadata.get(serverName)?.map(m => m.name) ?? [];
|
||||
}
|
||||
|
||||
export function totalToolCount(state: McpExtensionState): number {
|
||||
let count = 0;
|
||||
for (const metadata of state.toolMetadata.values()) {
|
||||
count += metadata.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function findToolByName(metadata: ToolMetadata[] | undefined, toolName: string): ToolMetadata | undefined {
|
||||
if (!metadata) return undefined;
|
||||
const exact = metadata.find(m => m.name === toolName);
|
||||
if (exact) return exact;
|
||||
const normalized = toolName.replace(/-/g, "_");
|
||||
return metadata.find(m => m.name.replace(/-/g, "_") === normalized);
|
||||
}
|
||||
|
||||
export function formatSchema(schema: unknown, indent = " "): string {
|
||||
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
return `${indent}(no schema)`;
|
||||
}
|
||||
|
||||
const s = schema as Record<string, unknown>;
|
||||
|
||||
if (s.type === "object" && s.properties && typeof s.properties === "object" && !Array.isArray(s.properties)) {
|
||||
const props = s.properties as Record<string, unknown>;
|
||||
const required = Array.isArray(s.required) ? s.required.filter((name): name is string => typeof name === "string") : [];
|
||||
|
||||
if (Object.keys(props).length === 0) {
|
||||
return `${indent}(no parameters)`;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const [name, propSchema] of Object.entries(props)) {
|
||||
lines.push(...formatProperty(name, propSchema, required.includes(name), indent));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const lines = formatNestedSchema(s, indent);
|
||||
if (lines.length > 0) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const typeStr = formatType(s);
|
||||
if (typeStr) {
|
||||
return `${indent}(${typeStr})`;
|
||||
}
|
||||
|
||||
return `${indent}(complex schema)`;
|
||||
}
|
||||
|
||||
function formatProperty(name: string, schema: unknown, required: boolean, indent: string): string[] {
|
||||
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
return [`${indent}${name}${required ? " *required*" : ""}`];
|
||||
}
|
||||
|
||||
const s = schema as Record<string, unknown>;
|
||||
const parts = [`${indent}${name}`];
|
||||
const typeStr = formatType(s);
|
||||
if (typeStr) parts.push(`(${typeStr})`);
|
||||
if (required) parts.push("*required*");
|
||||
appendSchemaAnnotations(parts, s);
|
||||
|
||||
return [parts.join(" "), ...formatNestedSchema(s, `${indent} `)];
|
||||
}
|
||||
|
||||
function formatNestedSchema(schema: Record<string, unknown>, indent: string): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
lines.push(...formatVariants("anyOf", schema.anyOf, indent));
|
||||
}
|
||||
if (Array.isArray(schema.oneOf)) {
|
||||
lines.push(...formatVariants("oneOf", schema.oneOf, indent));
|
||||
}
|
||||
if (schema.items !== undefined) {
|
||||
lines.push(...formatProperty("items", schema.items, false, indent));
|
||||
}
|
||||
if (schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties)) {
|
||||
const required = Array.isArray(schema.required) ? schema.required.filter((name): name is string => typeof name === "string") : [];
|
||||
for (const [name, propSchema] of Object.entries(schema.properties as Record<string, unknown>)) {
|
||||
lines.push(...formatProperty(name, propSchema, required.includes(name), indent));
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatVariants(keyword: "anyOf" | "oneOf", variants: unknown[], indent: string): string[] {
|
||||
const lines = [`${indent}${keyword}:`];
|
||||
|
||||
for (const variant of variants) {
|
||||
if (!variant || typeof variant !== "object" || Array.isArray(variant)) {
|
||||
lines.push(`${indent} - ${JSON.stringify(variant)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const s = variant as Record<string, unknown>;
|
||||
const typeStr = formatType(s) || "schema";
|
||||
const parts = [`${indent} - ${typeStr}`];
|
||||
appendSchemaAnnotations(parts, s);
|
||||
lines.push(parts.join(" "));
|
||||
lines.push(...formatNestedSchema(s, `${indent} `));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatType(schema: Record<string, unknown>): string {
|
||||
if (Object.hasOwn(schema, "const")) {
|
||||
return `const ${JSON.stringify(schema.const)}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return `enum: ${schema.enum.map(v => JSON.stringify(v)).join(", ")}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.type)) {
|
||||
return schema.type.map(type => String(type)).join(" | ");
|
||||
}
|
||||
|
||||
if (schema.type) {
|
||||
return String(schema.type);
|
||||
}
|
||||
|
||||
if (schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties)) {
|
||||
return "object";
|
||||
}
|
||||
|
||||
if (schema.items !== undefined) {
|
||||
return "array";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function appendSchemaAnnotations(parts: string[], schema: Record<string, unknown>): void {
|
||||
if (schema.description && typeof schema.description === "string") {
|
||||
parts.push(`- ${schema.description}`);
|
||||
}
|
||||
|
||||
for (const key of ["minLength", "maxLength", "minimum", "maximum", "minItems", "maxItems", "format", "pattern"] as const) {
|
||||
if (schema[key] !== undefined) {
|
||||
parts.push(`[${key}: ${JSON.stringify(schema[key])}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.default !== undefined) {
|
||||
parts.push(`[default: ${JSON.stringify(schema.default)}]`);
|
||||
}
|
||||
}
|
||||
46
agent/extensions/pi-mcp-adapter/tool-registrar.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// tool-registrar.ts - MCP content transformation
|
||||
// NOTE: Tools are NOT registered with Pi - only the unified `mcp` proxy tool is registered.
|
||||
// This keeps the LLM context small (1 tool instead of 100s).
|
||||
|
||||
import type { McpContent, ContentBlock } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Transform MCP content types to Pi content blocks.
|
||||
*/
|
||||
export function transformMcpContent(content: McpContent[]): ContentBlock[] {
|
||||
return content.map(c => {
|
||||
if (c.type === "text") {
|
||||
return { type: "text" as const, text: c.text ?? "" };
|
||||
}
|
||||
if (c.type === "image") {
|
||||
return {
|
||||
type: "image" as const,
|
||||
data: c.data ?? "",
|
||||
mimeType: c.mimeType ?? "image/png",
|
||||
};
|
||||
}
|
||||
if (c.type === "resource") {
|
||||
const resourceUri = c.resource?.uri ?? "(no URI)";
|
||||
const resourceContent = c.resource?.text ?? (c.resource ? JSON.stringify(c.resource) : "(no content)");
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `[Resource: ${resourceUri}]\n${resourceContent}`,
|
||||
};
|
||||
}
|
||||
if (c.type === "resource_link") {
|
||||
const linkName = c.name ?? c.uri ?? "unknown";
|
||||
const linkUri = c.uri ?? "(no URI)";
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `[Resource Link: ${linkName}]\nURI: ${linkUri}`,
|
||||
};
|
||||
}
|
||||
if (c.type === "audio") {
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `[Audio content: ${c.mimeType ?? "audio/*"}]`,
|
||||
};
|
||||
}
|
||||
return { type: "text" as const, text: JSON.stringify(c) };
|
||||
});
|
||||
}
|
||||
161
agent/extensions/pi-mcp-adapter/tool-result-renderer.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { AgentToolResult, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
|
||||
type McpToolResultDetails = Record<string, unknown> & { error?: unknown };
|
||||
type McpToolContentBlock = AgentToolResult<McpToolResultDetails>["content"][number];
|
||||
|
||||
interface RenderTheme {
|
||||
fg: (name: string, text: string) => string;
|
||||
bold?: (text: string) => string;
|
||||
}
|
||||
|
||||
export interface McpProxyToolCallInput {
|
||||
tool?: string;
|
||||
args?: string;
|
||||
connect?: string;
|
||||
describe?: string;
|
||||
search?: string;
|
||||
regex?: boolean;
|
||||
includeSchemas?: boolean;
|
||||
server?: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
interface McpToolRenderContext {
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export interface McpToolResultDisplay {
|
||||
lines: string[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_CALL_INPUT_CHARS = 1500;
|
||||
|
||||
function truncateText(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
|
||||
}
|
||||
|
||||
function formatJsonish(value: unknown, maxChars: number): string {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return truncateText(JSON.stringify(JSON.parse(value), null, 2), maxChars);
|
||||
} catch {
|
||||
return truncateText(value, maxChars);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return truncateText(JSON.stringify(value, null, 2), maxChars);
|
||||
} catch {
|
||||
return truncateText(String(value), maxChars);
|
||||
}
|
||||
}
|
||||
|
||||
function hasUsefulObjectContent(value: unknown): boolean {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
|
||||
}
|
||||
|
||||
export function formatMcpProxyToolCallLines(
|
||||
args: McpProxyToolCallInput,
|
||||
maxInputChars = DEFAULT_MAX_CALL_INPUT_CHARS,
|
||||
): string[] {
|
||||
if (args.action === "ui-messages") return [`mcp ${args.action}`];
|
||||
|
||||
if (args.tool) {
|
||||
const target = args.server ? `${args.tool} @ ${args.server}` : args.tool;
|
||||
const lines = [`mcp call ${target}`];
|
||||
if (args.args) lines.push(formatJsonish(args.args, maxInputChars));
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (args.connect) return [`mcp connect ${args.connect}`];
|
||||
if (args.describe) return [`mcp describe ${args.describe}`];
|
||||
|
||||
if (args.search) {
|
||||
let line = `mcp search ${args.search}`;
|
||||
if (args.server) line += ` @ ${args.server}`;
|
||||
if (args.regex === true) line += " (regex)";
|
||||
if (args.includeSchemas === false) line += " (schemas hidden)";
|
||||
return [line];
|
||||
}
|
||||
|
||||
if (args.server) return [`mcp list ${args.server}`];
|
||||
if (args.action) return [`mcp ${args.action}`];
|
||||
|
||||
return ["mcp status"];
|
||||
}
|
||||
|
||||
export function formatMcpDirectToolCallLines(
|
||||
displayName: string,
|
||||
args: Record<string, unknown>,
|
||||
maxInputChars = DEFAULT_MAX_CALL_INPUT_CHARS,
|
||||
): string[] {
|
||||
if (!hasUsefulObjectContent(args)) return [displayName];
|
||||
return [displayName, formatJsonish(args, maxInputChars)];
|
||||
}
|
||||
|
||||
function renderToolCallLines(lines: string[], theme: RenderTheme) {
|
||||
const [title = "mcp", ...rest] = lines;
|
||||
const styledTitle = theme.fg("toolTitle", theme.bold ? theme.bold(title) : title);
|
||||
const styledRest = rest.map(line => theme.fg("muted", line));
|
||||
return new Text([styledTitle, ...styledRest].join("\n"), 0, 0);
|
||||
}
|
||||
|
||||
export function renderMcpProxyToolCall(args: McpProxyToolCallInput, theme: RenderTheme) {
|
||||
return renderToolCallLines(formatMcpProxyToolCallLines(args), theme);
|
||||
}
|
||||
|
||||
export function createMcpDirectToolCallRenderer(displayName: string) {
|
||||
return (args: Record<string, unknown>, theme: RenderTheme) => {
|
||||
return renderToolCallLines(formatMcpDirectToolCallLines(displayName, args), theme);
|
||||
};
|
||||
}
|
||||
|
||||
function blockToLines(block: McpToolContentBlock): string[] {
|
||||
if (block.type === "text") {
|
||||
return block.text.split("\n");
|
||||
}
|
||||
return [`[image: ${block.mimeType}]`];
|
||||
}
|
||||
|
||||
export function formatMcpToolResultLines(
|
||||
result: Pick<AgentToolResult<McpToolResultDetails>, "content">,
|
||||
expanded: boolean,
|
||||
maxCollapsedLines = 3,
|
||||
): McpToolResultDisplay {
|
||||
const allLines = result.content.flatMap(blockToLines);
|
||||
const lines = allLines.length > 0 ? allLines : ["(empty result)"];
|
||||
|
||||
if (expanded || lines.length <= maxCollapsedLines) {
|
||||
return { lines, truncated: false };
|
||||
}
|
||||
|
||||
return {
|
||||
lines: [...lines.slice(0, maxCollapsedLines), "…"],
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderMcpToolResult(
|
||||
result: AgentToolResult<McpToolResultDetails>,
|
||||
options: ToolRenderResultOptions,
|
||||
theme: RenderTheme,
|
||||
context?: McpToolRenderContext,
|
||||
) {
|
||||
if (options.isPartial) {
|
||||
return new Text(theme.fg("warning", "Running MCP tool..."), 0, 0);
|
||||
}
|
||||
|
||||
const hasErrorDetails = Boolean(result.details.error);
|
||||
const display = formatMcpToolResultLines(result, options.expanded || context?.isError === true || hasErrorDetails);
|
||||
const output = display.lines
|
||||
.map((line) => line === "…" ? theme.fg("muted", line) : theme.fg("toolOutput", line))
|
||||
.join("\n");
|
||||
const hint = display.truncated && !options.expanded
|
||||
? `\n${theme.fg("muted", "(Ctrl+O to expand)")}`
|
||||
: "";
|
||||
|
||||
return new Text(`${output}${hint}`, 0, 0);
|
||||
}
|
||||
14
agent/extensions/pi-mcp-adapter/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": false,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
448
agent/extensions/pi-mcp-adapter/types.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
// types.ts - Core type definitions
|
||||
import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import type { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type { TextContent, ImageContent } from "@earendil-works/pi-ai";
|
||||
import type { UiStreamMode } from "./ui-stream-types.ts";
|
||||
|
||||
// Transport type (stdio + HTTP)
|
||||
export type Transport =
|
||||
| StdioClientTransport
|
||||
| SSEClientTransport
|
||||
| StreamableHTTPClientTransport;
|
||||
|
||||
// Import sources for config
|
||||
export type ImportKind =
|
||||
| "cursor"
|
||||
| "claude-code"
|
||||
| "claude-desktop"
|
||||
| "codex"
|
||||
| "windsurf"
|
||||
| "vscode";
|
||||
|
||||
// Tool definition from MCP server
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
inputSchema?: unknown; // JSON Schema
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Resource definition from MCP server
|
||||
export interface McpResource {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
mimeType?: string;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UiResourceMeta {
|
||||
csp?: UiResourceCsp;
|
||||
permissions?: UiResourcePermissions;
|
||||
domain?: string;
|
||||
prefersBorder?: boolean;
|
||||
}
|
||||
|
||||
export interface UiResourceContent {
|
||||
uri: string;
|
||||
html: string;
|
||||
mimeType?: string;
|
||||
meta: UiResourceMeta;
|
||||
}
|
||||
|
||||
export interface UiProxyRequestBody<TParams> {
|
||||
token: string;
|
||||
params: TParams;
|
||||
}
|
||||
|
||||
export interface UiProxyResult<T = Record<string, unknown>> {
|
||||
ok: boolean;
|
||||
result?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UiResourceCsp {
|
||||
connectDomains?: string[];
|
||||
scriptDomains?: string[];
|
||||
styleDomains?: string[];
|
||||
fontDomains?: string[];
|
||||
imgDomains?: string[];
|
||||
mediaDomains?: string[];
|
||||
frameDomains?: string[];
|
||||
workerDomains?: string[];
|
||||
baseUriDomains?: string[];
|
||||
}
|
||||
|
||||
export interface UiResourcePermissions {
|
||||
camera?: {};
|
||||
microphone?: {};
|
||||
geolocation?: {};
|
||||
clipboardWrite?: {};
|
||||
}
|
||||
|
||||
export interface UiToolInfo {
|
||||
id?: string | number;
|
||||
tool: {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UiHostContext {
|
||||
toolInfo?: UiToolInfo;
|
||||
theme?: "light" | "dark";
|
||||
styles?: Record<string, unknown>;
|
||||
displayMode?: UiDisplayMode;
|
||||
availableDisplayModes?: UiDisplayMode[];
|
||||
containerDimensions?: {
|
||||
width?: number;
|
||||
maxWidth?: number;
|
||||
height?: number;
|
||||
maxHeight?: number;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type UiDisplayMode = "inline" | "fullscreen" | "pip";
|
||||
|
||||
// Re-export stream types from the shared lightweight module.
|
||||
// This allows the example package to import stream schemas without pulling the full types.ts dependency graph.
|
||||
export {
|
||||
UI_STREAM_HOST_CONTEXT_KEY,
|
||||
UI_STREAM_REQUEST_META_KEY,
|
||||
UI_STREAM_RESULT_PATCH_METHOD,
|
||||
SERVER_STREAM_RESULT_PATCH_METHOD,
|
||||
UI_STREAM_STRUCTURED_CONTENT_KEY,
|
||||
uiStreamModeSchema,
|
||||
visualizationStreamPhaseSchema,
|
||||
visualizationStreamFrameTypeSchema,
|
||||
visualizationStreamStatusSchema,
|
||||
uiStreamHostContextSchema,
|
||||
visualizationStreamEnvelopeSchema,
|
||||
uiStreamCallToolResultSchema,
|
||||
uiStreamResultPatchNotificationSchema,
|
||||
serverStreamResultPatchNotificationSchema,
|
||||
getUiStreamHostContext,
|
||||
getVisualizationStreamEnvelope,
|
||||
type UiStreamMode,
|
||||
type VisualizationStreamPhase,
|
||||
type VisualizationStreamFrameType,
|
||||
type VisualizationStreamStatus,
|
||||
type UiStreamHostContext,
|
||||
type VisualizationStreamEnvelope,
|
||||
type UiStreamCallToolResult,
|
||||
type UiStreamResultPatchNotification,
|
||||
type ServerStreamResultPatchNotification,
|
||||
type UiStreamSummary,
|
||||
} from "./ui-stream-types.ts";
|
||||
|
||||
export interface UiMessageParams {
|
||||
role?: string;
|
||||
content?: unknown[];
|
||||
type?: "prompt" | "notify" | "intent" | "message";
|
||||
message?: string;
|
||||
prompt?: string;
|
||||
intent?: string;
|
||||
params?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prompt text from either legacy MCP UI message shapes or native AppBridge user messages.
|
||||
*/
|
||||
export function extractUiPromptText(params: UiMessageParams): string | undefined {
|
||||
if (params.type === "prompt" || params.prompt) {
|
||||
const prompt = params.prompt ?? String(params.message ?? "");
|
||||
return prompt || undefined;
|
||||
}
|
||||
|
||||
if (params.role === "user" && Array.isArray(params.content)) {
|
||||
const text = params.content
|
||||
.map((block) => (block && typeof block === "object" && "text" in block ? String((block as { text?: unknown }).text ?? "") : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured UI handoff recovered from a canonical prompt envelope.
|
||||
*/
|
||||
export interface UiPromptHandoff {
|
||||
intent: string;
|
||||
params: Record<string, unknown>;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a canonical named UI handoff encoded as `intent\n{json}`.
|
||||
*/
|
||||
export function parseUiPromptHandoff(prompt: string): UiPromptHandoff | undefined {
|
||||
const newlineIndex = prompt.indexOf("\n");
|
||||
if (newlineIndex <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const intent = prompt.slice(0, newlineIndex).trim();
|
||||
const payloadText = prompt.slice(newlineIndex + 1).trim();
|
||||
if (!intent || !payloadText) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(intent)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payloadText);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
intent,
|
||||
params: parsed as Record<string, unknown>,
|
||||
raw: prompt,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulated messages from a UI session.
|
||||
* Collected during the session and available when it ends.
|
||||
*/
|
||||
export interface UiSessionMessages {
|
||||
prompts: string[];
|
||||
notifications: string[];
|
||||
intents: Array<{ intent: string; params?: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
export interface UiModelContextParams {
|
||||
content?: unknown[];
|
||||
structuredContent?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UiOpenLinkResult {
|
||||
isError?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UiDisplayModeRequest {
|
||||
mode?: UiDisplayMode;
|
||||
}
|
||||
|
||||
export interface UiDisplayModeResult {
|
||||
mode: UiDisplayMode;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Content types from MCP
|
||||
export interface McpContent {
|
||||
type: "text" | "image" | "audio" | "resource" | "resource_link";
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
resource?: {
|
||||
uri: string;
|
||||
text?: string;
|
||||
blob?: string;
|
||||
};
|
||||
uri?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Pi content block type
|
||||
export type ContentBlock = TextContent | ImageContent;
|
||||
|
||||
// OAuth configuration (SDK handles auto-discovery and dynamic registration)
|
||||
export interface OAuthConfig {
|
||||
/** OAuth grant type (defaults to authorization_code) */
|
||||
grantType?: "authorization_code" | "client_credentials";
|
||||
/** Pre-registered client ID (optional, dynamic registration used if not provided) */
|
||||
clientId?: string;
|
||||
/** Client secret for confidential clients */
|
||||
clientSecret?: string;
|
||||
/** Requested OAuth scopes */
|
||||
scope?: string;
|
||||
/** Exact authorization-code redirect URI for pre-registered clients */
|
||||
redirectUri?: string;
|
||||
/** Client display name for dynamic registration */
|
||||
clientName?: string;
|
||||
/** Client homepage URI for dynamic registration */
|
||||
clientUri?: string;
|
||||
}
|
||||
|
||||
// Server configuration
|
||||
export interface ServerEntry {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
// HTTP fields
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* Authentication type:
|
||||
* - 'oauth' - Use OAuth 2.1 (auto-discovers endpoints, supports dynamic client registration)
|
||||
* - 'bearer' - Use static Bearer token
|
||||
* - false - Disable authentication
|
||||
* If not specified and url is present, OAuth will be auto-detected
|
||||
*/
|
||||
auth?: "oauth" | "bearer" | false;
|
||||
bearerToken?: string;
|
||||
bearerTokenEnv?: string;
|
||||
/**
|
||||
* OAuth configuration (optional).
|
||||
* If not provided, the SDK will attempt dynamic client registration.
|
||||
* Set to false to explicitly disable OAuth for this server.
|
||||
*/
|
||||
oauth?: OAuthConfig | false;
|
||||
lifecycle?: "keep-alive" | "lazy" | "eager";
|
||||
idleTimeout?: number; // minutes, overrides global setting
|
||||
// Resource handling
|
||||
exposeResources?: boolean;
|
||||
// Direct tool registration
|
||||
directTools?: boolean | string[];
|
||||
// Exclude specific MCP tools/resources by original or prefixed name
|
||||
excludeTools?: string[];
|
||||
// Debug
|
||||
debug?: boolean; // Show server stderr (default: false)
|
||||
}
|
||||
|
||||
// Settings
|
||||
export interface McpSettings {
|
||||
toolPrefix?: "server" | "none" | "short";
|
||||
idleTimeout?: number; // minutes, default 10, 0 to disable
|
||||
directTools?: boolean;
|
||||
disableProxyTool?: boolean;
|
||||
autoAuth?: boolean;
|
||||
sampling?: boolean;
|
||||
samplingAutoApprove?: boolean;
|
||||
elicitation?: boolean;
|
||||
/**
|
||||
* Message returned in tool results when a server needs (re-)authentication.
|
||||
* "${server}" is substituted with the server name. Defaults to a TUI
|
||||
* instruction when unset.
|
||||
*/
|
||||
authRequiredMessage?: string;
|
||||
}
|
||||
|
||||
// Root config
|
||||
export interface McpConfig {
|
||||
mcpServers: Record<string, ServerEntry>;
|
||||
imports?: ImportKind[];
|
||||
settings?: McpSettings;
|
||||
}
|
||||
|
||||
// Alias for clarity
|
||||
export type ServerDefinition = ServerEntry;
|
||||
|
||||
export interface ToolMetadata {
|
||||
name: string; // Prefixed tool name (e.g., "xcodebuild_list_sims")
|
||||
originalName: string; // Original MCP tool name (e.g., "list_sims")
|
||||
description: string;
|
||||
resourceUri?: string; // For resource tools: the URI to read
|
||||
uiResourceUri?: string; // For app-enabled tools: the UI resource URI
|
||||
inputSchema?: unknown; // JSON Schema for parameters (stored for describe/errors)
|
||||
uiStreamMode?: UiStreamMode;
|
||||
}
|
||||
|
||||
export interface DirectToolSpec {
|
||||
serverName: string;
|
||||
originalName: string;
|
||||
prefixedName: string;
|
||||
description: string;
|
||||
inputSchema?: unknown;
|
||||
resourceUri?: string;
|
||||
uiResourceUri?: string;
|
||||
uiStreamMode?: UiStreamMode;
|
||||
}
|
||||
|
||||
export interface ServerProvenance {
|
||||
path: string;
|
||||
kind: "user" | "project" | "import";
|
||||
importKind?: string;
|
||||
}
|
||||
|
||||
export interface McpAuthResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface McpPanelCallbacks {
|
||||
reconnect: (serverName: string) => Promise<boolean>;
|
||||
canAuthenticate: (serverName: string) => boolean;
|
||||
authenticate: (serverName: string) => Promise<McpAuthResult>;
|
||||
getConnectionStatus: (serverName: string) => "connected" | "idle" | "failed" | "needs-auth";
|
||||
refreshCacheAfterReconnect: (serverName: string) => import("./metadata-cache.ts").ServerCacheEntry | null;
|
||||
}
|
||||
|
||||
export interface McpPanelResult {
|
||||
changes: Map<string, true | string[] | false>;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server prefix based on tool prefix mode.
|
||||
*/
|
||||
export function getServerPrefix(
|
||||
serverName: string,
|
||||
mode: "server" | "none" | "short"
|
||||
): string {
|
||||
if (mode === "none") return "";
|
||||
if (mode === "short") {
|
||||
let short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
|
||||
if (!short) short = "mcp";
|
||||
return short;
|
||||
}
|
||||
return serverName.replace(/-/g, "_");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a tool name with server prefix.
|
||||
*/
|
||||
export function formatToolName(
|
||||
toolName: string,
|
||||
serverName: string,
|
||||
prefix: "server" | "none" | "short"
|
||||
): string {
|
||||
const p = getServerPrefix(serverName, prefix);
|
||||
return p ? `${p}_${toolName}` : toolName;
|
||||
}
|
||||
|
||||
function normalizeToolName(value: string): string {
|
||||
return value.replace(/-/g, "_");
|
||||
}
|
||||
|
||||
export function isToolExcluded(
|
||||
toolName: string,
|
||||
serverName: string,
|
||||
prefix: "server" | "none" | "short",
|
||||
excludeTools?: unknown
|
||||
): boolean {
|
||||
if (!Array.isArray(excludeTools) || excludeTools.length === 0) return false;
|
||||
|
||||
const candidates = new Set<string>([
|
||||
normalizeToolName(toolName),
|
||||
normalizeToolName(formatToolName(toolName, serverName, prefix)),
|
||||
normalizeToolName(formatToolName(toolName, serverName, "server")),
|
||||
normalizeToolName(formatToolName(toolName, serverName, "short")),
|
||||
]);
|
||||
|
||||
for (const excluded of excludeTools) {
|
||||
if (typeof excluded !== "string") continue;
|
||||
if (candidates.has(normalizeToolName(excluded))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
146
agent/extensions/pi-mcp-adapter/ui-resource-handler.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import { UrlElicitationRequiredError, type ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { ResourceFetchError, ResourceParseError } from "./errors.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import type { McpServerManager } from "./server-manager.ts";
|
||||
import type { UiResourceContent, UiResourceMeta } from "./types.ts";
|
||||
|
||||
interface ResourceContentRecord {
|
||||
uri?: string;
|
||||
mimeType?: string;
|
||||
text?: string;
|
||||
blob?: string;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class UiResourceHandler {
|
||||
private log = logger.child({ component: "UiResourceHandler" });
|
||||
|
||||
constructor(private manager: McpServerManager) {}
|
||||
|
||||
async readUiResource(serverName: string, uri: string): Promise<UiResourceContent> {
|
||||
const log = this.log.child({ server: serverName, uri });
|
||||
|
||||
if (!uri.startsWith("ui://")) {
|
||||
throw new ResourceParseError(uri, "URI must start with ui://", { server: serverName });
|
||||
}
|
||||
|
||||
log.debug("Fetching UI resource");
|
||||
|
||||
let result: ReadResourceResult;
|
||||
try {
|
||||
result = await this.manager.readResource(serverName, uri);
|
||||
} catch (error) {
|
||||
if (error instanceof UrlElicitationRequiredError) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error("Failed to read resource", error instanceof Error ? error : undefined);
|
||||
throw new ResourceFetchError(uri, message, {
|
||||
server: serverName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const content = selectContent(result, uri);
|
||||
const mimeType = content.mimeType;
|
||||
|
||||
if (mimeType && !isHtmlMimeType(mimeType)) {
|
||||
log.warn("Unsupported MIME type", { mimeType });
|
||||
throw new ResourceParseError(
|
||||
uri,
|
||||
`unsupported MIME type "${mimeType}" (expected text/html or ${RESOURCE_MIME_TYPE})`,
|
||||
{ server: serverName, mimeType }
|
||||
);
|
||||
}
|
||||
|
||||
const html = toHtml(content);
|
||||
if (!html.trim()) {
|
||||
log.warn("Resource content is empty");
|
||||
throw new ResourceParseError(uri, "content is empty", { server: serverName });
|
||||
}
|
||||
|
||||
const contentMeta = extractUiMeta(content._meta);
|
||||
const listMeta = extractUiMeta(this.getListResourceMeta(serverName, uri));
|
||||
|
||||
log.debug("Resource loaded successfully", {
|
||||
contentLength: html.length,
|
||||
hasCsp: !!contentMeta.csp || !!listMeta.csp,
|
||||
});
|
||||
|
||||
return {
|
||||
uri: content.uri ?? uri,
|
||||
html,
|
||||
mimeType: mimeType ?? RESOURCE_MIME_TYPE,
|
||||
meta: {
|
||||
csp: contentMeta.csp ?? listMeta.csp,
|
||||
permissions: contentMeta.permissions ?? listMeta.permissions,
|
||||
domain: contentMeta.domain ?? listMeta.domain,
|
||||
prefersBorder: contentMeta.prefersBorder ?? listMeta.prefersBorder,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getListResourceMeta(serverName: string, uri: string): Record<string, unknown> | undefined {
|
||||
const connection = this.manager.getConnection(serverName);
|
||||
if (!connection?.resources?.length) return undefined;
|
||||
const resource = connection.resources.find((entry) => entry.uri === uri);
|
||||
if (!resource || !resource._meta || typeof resource._meta !== "object") return undefined;
|
||||
return resource._meta;
|
||||
}
|
||||
}
|
||||
|
||||
function selectContent(result: ReadResourceResult, preferredUri: string): ResourceContentRecord {
|
||||
const contents = (result.contents ?? []) as ResourceContentRecord[];
|
||||
if (contents.length === 0) {
|
||||
throw new Error(`No contents returned for UI resource: ${preferredUri}`);
|
||||
}
|
||||
|
||||
const byUri = contents.find((content) => content.uri === preferredUri);
|
||||
if (byUri) return byUri;
|
||||
|
||||
const byHtmlMime = contents.find(
|
||||
(content) => content.mimeType && isHtmlMimeType(content.mimeType)
|
||||
);
|
||||
if (byHtmlMime) return byHtmlMime;
|
||||
|
||||
return contents[0];
|
||||
}
|
||||
|
||||
function isHtmlMimeType(mimeType: string): boolean {
|
||||
const normalized = mimeType.toLowerCase();
|
||||
return normalized.startsWith("text/html") || normalized === RESOURCE_MIME_TYPE.toLowerCase();
|
||||
}
|
||||
|
||||
function toHtml(content: ResourceContentRecord): string {
|
||||
if (typeof content.text === "string") {
|
||||
return content.text;
|
||||
}
|
||||
|
||||
if (typeof content.blob === "string") {
|
||||
return Buffer.from(content.blob, "base64").toString("utf-8");
|
||||
}
|
||||
|
||||
throw new Error(`UI resource ${content.uri ?? "(unknown)"} did not include text or blob content`);
|
||||
}
|
||||
|
||||
function extractUiMeta(meta: Record<string, unknown> | undefined): UiResourceMeta {
|
||||
if (!meta || typeof meta !== "object") return {};
|
||||
const ui = meta.ui as Record<string, unknown> | undefined;
|
||||
if (!ui || typeof ui !== "object") return {};
|
||||
|
||||
const out: UiResourceMeta = {};
|
||||
|
||||
if (ui.csp && typeof ui.csp === "object") {
|
||||
out.csp = ui.csp as UiResourceMeta["csp"];
|
||||
}
|
||||
if (ui.permissions && typeof ui.permissions === "object") {
|
||||
out.permissions = ui.permissions as UiResourceMeta["permissions"];
|
||||
}
|
||||
if (typeof ui.domain === "string") {
|
||||
out.domain = ui.domain;
|
||||
}
|
||||
if (typeof ui.prefersBorder === "boolean") {
|
||||
out.prefersBorder = ui.prefersBorder;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
623
agent/extensions/pi-mcp-adapter/ui-server.ts
Normal file
@@ -0,0 +1,623 @@
|
||||
import http, { type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { buildAllowAttribute } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type {
|
||||
CallToolRequest,
|
||||
CallToolResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { ConsentManager } from "./consent-manager.ts";
|
||||
import { ServerError, wrapError } from "./errors.ts";
|
||||
import { buildHostHtmlTemplate, buildCspMetaContent, applyCspMeta } from "./host-html-template.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import type { McpServerManager } from "./server-manager.ts";
|
||||
import {
|
||||
extractUiPromptText,
|
||||
getVisualizationStreamEnvelope,
|
||||
type UiDisplayMode,
|
||||
type UiDisplayModeRequest,
|
||||
type UiDisplayModeResult,
|
||||
type UiHostContext,
|
||||
type UiMessageParams,
|
||||
type UiModelContextParams,
|
||||
type UiOpenLinkResult,
|
||||
type UiProxyRequestBody,
|
||||
type UiProxyResult,
|
||||
type UiResourceContent,
|
||||
type UiSessionMessages,
|
||||
type UiStreamSummary,
|
||||
} from "./types.ts";
|
||||
|
||||
const MAX_BODY_SIZE = 2 * 1024 * 1024;
|
||||
const ABANDONED_GRACE_MS = 60_000;
|
||||
const WATCHDOG_INTERVAL_MS = 5_000;
|
||||
const MAX_EVENT_LOG = 128;
|
||||
|
||||
export interface UiServerOptions {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
resource: UiResourceContent;
|
||||
manager: McpServerManager;
|
||||
consentManager: ConsentManager;
|
||||
hostContext?: UiHostContext;
|
||||
initialResultPromise?: Promise<CallToolResult>;
|
||||
sessionToken?: string;
|
||||
port?: number;
|
||||
onMessage?: (params: UiMessageParams) => Promise<void> | void;
|
||||
onContextUpdate?: (params: UiModelContextParams) => Promise<void> | void;
|
||||
onComplete?: (reason: string) => void;
|
||||
}
|
||||
|
||||
export interface UiServerHandle {
|
||||
url: string;
|
||||
port: number;
|
||||
sessionToken: string;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
close: (reason?: string) => void;
|
||||
sendToolInput: (args: Record<string, unknown>) => void;
|
||||
sendToolResult: (result: CallToolResult) => void;
|
||||
sendResultPatch: (result: CallToolResult) => void;
|
||||
sendToolCancelled: (reason: string) => void;
|
||||
sendHostContext: (context: UiHostContext) => void;
|
||||
/** Get accumulated messages from this session */
|
||||
getSessionMessages: () => UiSessionMessages;
|
||||
getStreamSummary: () => UiStreamSummary | undefined;
|
||||
}
|
||||
|
||||
export async function startUiServer(options: UiServerOptions): Promise<UiServerHandle> {
|
||||
const sessionToken = options.sessionToken ?? randomUUID();
|
||||
const log = logger.child({
|
||||
component: "UiServer",
|
||||
server: options.serverName,
|
||||
tool: options.toolName,
|
||||
session: sessionToken.slice(0, 8),
|
||||
});
|
||||
|
||||
log.debug("Starting UI server");
|
||||
|
||||
const sseClients = new Set<ServerResponse>();
|
||||
let completed = false;
|
||||
let lastHeartbeatAt = Date.now();
|
||||
let watchdog: NodeJS.Timeout | null = null;
|
||||
let currentDisplayMode: UiDisplayMode = options.hostContext?.displayMode ?? "inline";
|
||||
let nextEventId = 1;
|
||||
const eventLog: Array<{ id: number; name: string; payload: unknown }> = [];
|
||||
let streamSummary: UiStreamSummary | undefined;
|
||||
|
||||
// Track messages from UI for retrieval
|
||||
const sessionMessages: UiSessionMessages = {
|
||||
prompts: [],
|
||||
notifications: [],
|
||||
intents: [],
|
||||
};
|
||||
|
||||
const hostContext: UiHostContext = {
|
||||
displayMode: currentDisplayMode,
|
||||
availableDisplayModes: ["inline", "fullscreen", "pip"],
|
||||
platform: "desktop",
|
||||
...options.hostContext,
|
||||
// Only include toolInfo if caller provides full tool definition with inputSchema
|
||||
// The App validates toolInfo.tool.inputSchema as required object
|
||||
};
|
||||
|
||||
const initialStreamContext = hostContext["pi-mcp-adapter/stream"];
|
||||
if (initialStreamContext && typeof initialStreamContext === "object") {
|
||||
const streamId = (initialStreamContext as { streamId?: unknown }).streamId;
|
||||
const mode = (initialStreamContext as { mode?: unknown }).mode;
|
||||
if (typeof streamId === "string" && (mode === "eager" || mode === "stream-first")) {
|
||||
streamSummary = {
|
||||
streamId,
|
||||
mode,
|
||||
frames: 0,
|
||||
phases: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const touchHeartbeat = () => {
|
||||
lastHeartbeatAt = Date.now();
|
||||
};
|
||||
|
||||
const updateStreamSummary = (payload: unknown) => {
|
||||
const envelope = getVisualizationStreamEnvelope((payload as { structuredContent?: unknown } | null)?.structuredContent);
|
||||
if (!envelope) return;
|
||||
if (!streamSummary) {
|
||||
streamSummary = {
|
||||
streamId: envelope.streamId,
|
||||
mode: "eager",
|
||||
frames: 0,
|
||||
phases: [],
|
||||
};
|
||||
}
|
||||
streamSummary.frames += 1;
|
||||
if (!streamSummary.phases.includes(envelope.phase)) {
|
||||
streamSummary.phases.push(envelope.phase);
|
||||
}
|
||||
streamSummary.finalStatus = envelope.status;
|
||||
streamSummary.lastMessage = envelope.message;
|
||||
};
|
||||
|
||||
const serializeEvent = (eventId: number, name: string, payload: unknown): string => {
|
||||
return `id: ${eventId}\nevent: ${name}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
};
|
||||
|
||||
const getLatestCheckpointIndex = () => {
|
||||
for (let index = eventLog.length - 1; index >= 0; index -= 1) {
|
||||
const entry = eventLog[index];
|
||||
const envelope = getVisualizationStreamEnvelope((entry.payload as { structuredContent?: unknown } | null)?.structuredContent);
|
||||
if (envelope?.frameType === "checkpoint" || envelope?.frameType === "final") {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const pruneEventLog = () => {
|
||||
if (eventLog.length <= MAX_EVENT_LOG) return;
|
||||
const latestCheckpointIndex = getLatestCheckpointIndex();
|
||||
|
||||
if (latestCheckpointIndex > 0) {
|
||||
eventLog.splice(0, latestCheckpointIndex);
|
||||
}
|
||||
|
||||
if (eventLog.length > MAX_EVENT_LOG) {
|
||||
eventLog.splice(0, eventLog.length - MAX_EVENT_LOG);
|
||||
}
|
||||
};
|
||||
|
||||
const pushEvent = (name: string, payload: unknown) => {
|
||||
if (completed) return;
|
||||
const eventId = nextEventId++;
|
||||
eventLog.push({ id: eventId, name, payload });
|
||||
updateStreamSummary(payload);
|
||||
pruneEventLog();
|
||||
const chunk = serializeEvent(eventId, name, payload);
|
||||
for (const client of sseClients) {
|
||||
try {
|
||||
client.write(chunk);
|
||||
} catch {
|
||||
sseClients.delete(client);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const replayEvents = (res: ServerResponse, lastEventIdHeader?: string | null) => {
|
||||
const parsedLastId = lastEventIdHeader ? Number(lastEventIdHeader) : Number.NaN;
|
||||
const eventsToReplay = Number.isFinite(parsedLastId)
|
||||
? eventLog.filter((entry) => entry.id > parsedLastId)
|
||||
: (() => {
|
||||
const latestCheckpointIndex = getLatestCheckpointIndex();
|
||||
return latestCheckpointIndex >= 0 ? eventLog.slice(latestCheckpointIndex) : eventLog;
|
||||
})();
|
||||
|
||||
for (const entry of eventsToReplay) {
|
||||
try {
|
||||
res.write(serializeEvent(entry.id, entry.name, entry.payload));
|
||||
} catch {
|
||||
sseClients.delete(res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeSse = () => {
|
||||
for (const client of sseClients) {
|
||||
try {
|
||||
client.end();
|
||||
} catch {}
|
||||
}
|
||||
sseClients.clear();
|
||||
};
|
||||
|
||||
const stopWatchdog = () => {
|
||||
if (!watchdog) return;
|
||||
clearInterval(watchdog);
|
||||
watchdog = null;
|
||||
};
|
||||
|
||||
const markCompleted = (reason: string) => {
|
||||
if (completed) return;
|
||||
log.debug("Session completed", { reason });
|
||||
pushEvent("session-complete", { reason });
|
||||
completed = true;
|
||||
stopWatchdog();
|
||||
options.onComplete?.(reason);
|
||||
};
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const method = req.method || "GET";
|
||||
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
||||
|
||||
if (method === "GET" && url.pathname === "/") {
|
||||
if (!validateTokenQuery(url, sessionToken, res)) return;
|
||||
touchHeartbeat();
|
||||
|
||||
const html = buildHostHtmlTemplate({
|
||||
sessionToken,
|
||||
serverName: options.serverName,
|
||||
toolName: options.toolName,
|
||||
toolArgs: options.toolArgs,
|
||||
resource: options.resource,
|
||||
allowAttribute: buildAllowAttribute(options.resource.meta.permissions),
|
||||
requireToolConsent: options.consentManager.requiresPrompt(options.serverName),
|
||||
cacheToolConsent: options.consentManager.shouldCacheConsent(),
|
||||
hostContext,
|
||||
});
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && url.pathname === "/events") {
|
||||
if (!validateTokenQuery(url, sessionToken, res)) return;
|
||||
touchHeartbeat();
|
||||
log.debug("SSE client connected", { clientCount: sseClients.size + 1 });
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.write(": connected\n\n");
|
||||
sseClients.add(res);
|
||||
replayEvents(res, req.headers["last-event-id"] ? String(req.headers["last-event-id"]) : null);
|
||||
req.on("close", () => {
|
||||
sseClients.delete(res);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && url.pathname === "/health") {
|
||||
if (!validateTokenQuery(url, sessionToken, res)) return;
|
||||
sendJson(res, 200, { ok: true, result: { healthy: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && url.pathname === "/ui-app") {
|
||||
if (!validateTokenQuery(url, sessionToken, res)) return;
|
||||
touchHeartbeat();
|
||||
// Serve the MCP app's UI HTML directly (avoids blob URL security issues)
|
||||
// Apply CSP meta tag if specified in resource metadata
|
||||
const cspContent = buildCspMetaContent(options.resource.meta.csp);
|
||||
const appHtml = applyCspMeta(options.resource.html, cspContent);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(appHtml);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && url.pathname === "/app-bridge.bundle.js") {
|
||||
// Serve the pre-bundled AppBridge module
|
||||
const bundlePath = path.join(import.meta.dirname, "app-bridge.bundle.js");
|
||||
try {
|
||||
const content = await fs.readFile(bundlePath, "utf-8");
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/javascript",
|
||||
"Cache-Control": "public, max-age=31536000",
|
||||
});
|
||||
res.end(content);
|
||||
} catch {
|
||||
sendJson(res, 500, { ok: false, error: "Bundle not found" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (method !== "POST") {
|
||||
sendJson(res, 404, { ok: false, error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await parseBody(req, res);
|
||||
if (!body) return;
|
||||
if (!validateTokenBody(body, sessionToken, res)) return;
|
||||
const params = body.params ?? {};
|
||||
touchHeartbeat();
|
||||
|
||||
if (url.pathname === "/proxy/tools/call") {
|
||||
options.consentManager.ensureApproved(options.serverName);
|
||||
const callParams = params as CallToolRequest["params"];
|
||||
if (!callParams || typeof callParams.name !== "string" || !callParams.name.trim()) {
|
||||
sendJson(res, 400, { ok: false, error: "Invalid tools/call params" });
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = options.manager.getConnection(options.serverName);
|
||||
if (!connection || connection.status !== "connected") {
|
||||
sendJson(res, 503, { ok: false, error: `Server "${options.serverName}" is not connected` });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
options.manager.touch(options.serverName);
|
||||
options.manager.incrementInFlight(options.serverName);
|
||||
const result = await connection.client.callTool({
|
||||
name: callParams.name,
|
||||
arguments:
|
||||
callParams.arguments && typeof callParams.arguments === "object" && !Array.isArray(callParams.arguments)
|
||||
? callParams.arguments
|
||||
: {},
|
||||
});
|
||||
sendJson(res, 200, { ok: true, result });
|
||||
} finally {
|
||||
options.manager.decrementInFlight(options.serverName);
|
||||
options.manager.touch(options.serverName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/consent") {
|
||||
const approved = !!(params as { approved?: boolean }).approved;
|
||||
options.consentManager.registerDecision(options.serverName, approved);
|
||||
sendJson(res, 200, { ok: true, result: { approved } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/message") {
|
||||
const msgParams = params as UiMessageParams;
|
||||
const promptText = extractUiPromptText(msgParams);
|
||||
|
||||
// Track messages by type (order: prompt → intent → notify)
|
||||
// Must match the order in index.ts onMessage handler
|
||||
if (promptText) {
|
||||
sessionMessages.prompts.push(promptText);
|
||||
log.debug("UI prompt received", { prompt: promptText.slice(0, 100) });
|
||||
} else if (msgParams.type === "intent" || msgParams.intent) {
|
||||
const intentName = msgParams.intent ?? "";
|
||||
if (intentName) {
|
||||
sessionMessages.intents.push({
|
||||
intent: intentName,
|
||||
params: msgParams.params
|
||||
});
|
||||
log.debug("UI intent received", { intent: intentName });
|
||||
}
|
||||
} else if (msgParams.type === "notify" || msgParams.message) {
|
||||
const notifyText = msgParams.message ?? "";
|
||||
if (notifyText) {
|
||||
sessionMessages.notifications.push(notifyText);
|
||||
log.debug("UI notification", { message: notifyText.slice(0, 100) });
|
||||
}
|
||||
}
|
||||
|
||||
await options.onMessage?.(msgParams);
|
||||
sendJson(res, 200, { ok: true, result: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/context") {
|
||||
const ctxParams = params as UiModelContextParams;
|
||||
log.debug("UI context update", { hasContent: !!ctxParams.content });
|
||||
await options.onContextUpdate?.(ctxParams);
|
||||
sendJson(res, 200, { ok: true, result: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/open-link") {
|
||||
const openParams = params as { url?: string };
|
||||
if (!openParams?.url || typeof openParams.url !== "string") {
|
||||
sendJson(res, 400, { ok: false, error: "Invalid open-link params" });
|
||||
return;
|
||||
}
|
||||
let result: UiOpenLinkResult = {};
|
||||
try {
|
||||
new URL(openParams.url);
|
||||
} catch {
|
||||
result = { isError: true };
|
||||
}
|
||||
sendJson(res, 200, { ok: true, result });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/download-file") {
|
||||
sendJson(res, 200, { ok: true, result: { isError: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/request-display-mode") {
|
||||
const displayParams = params as UiDisplayModeRequest;
|
||||
const requested = displayParams?.mode;
|
||||
const available = hostContext.availableDisplayModes ?? ["inline"];
|
||||
if (requested && available.includes(requested)) {
|
||||
currentDisplayMode = requested;
|
||||
}
|
||||
hostContext.displayMode = currentDisplayMode;
|
||||
pushEvent("host-context", { displayMode: currentDisplayMode });
|
||||
const result: UiDisplayModeResult = { mode: currentDisplayMode };
|
||||
sendJson(res, 200, { ok: true, result });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/heartbeat") {
|
||||
sendJson(res, 200, { ok: true, result: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/proxy/ui/complete") {
|
||||
const reason = typeof (params as { reason?: string }).reason === "string"
|
||||
? (params as { reason?: string }).reason!
|
||||
: "done";
|
||||
markCompleted(reason);
|
||||
sendJson(res, 200, { ok: true, result: {} });
|
||||
setTimeout(() => {
|
||||
try {
|
||||
server.close();
|
||||
} catch {}
|
||||
closeSse();
|
||||
}, 20).unref();
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 404, { ok: false, error: "Not found" });
|
||||
} catch (error) {
|
||||
const wrapped = wrapError(error, { server: options.serverName, tool: options.toolName });
|
||||
const status = /approval required|denied/i.test(wrapped.message) ? 403 : 500;
|
||||
if (status === 500) {
|
||||
log.error("Request handler error", error instanceof Error ? error : undefined);
|
||||
}
|
||||
sendJson(res, status, { ok: false, error: wrapped.message });
|
||||
}
|
||||
});
|
||||
|
||||
if (options.initialResultPromise) {
|
||||
options.initialResultPromise.then(
|
||||
(result) => pushEvent("tool-result", result),
|
||||
(error) => {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
pushEvent("tool-cancelled", { reason });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
watchdog = setInterval(() => {
|
||||
if (completed) return;
|
||||
if (Date.now() - lastHeartbeatAt <= ABANDONED_GRACE_MS) return;
|
||||
markCompleted("stale");
|
||||
try {
|
||||
server.close();
|
||||
} catch {}
|
||||
closeSse();
|
||||
}, WATCHDOG_INTERVAL_MS);
|
||||
watchdog.unref();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const onError = (error: Error) => {
|
||||
log.error("Failed to start server", error);
|
||||
reject(new ServerError(error.message, { port: options.port, cause: error }));
|
||||
};
|
||||
|
||||
server.once("error", onError);
|
||||
server.listen(options.port ?? 0, "127.0.0.1", () => {
|
||||
server.off("error", onError);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
const err = new ServerError("invalid address");
|
||||
log.error("Invalid server address", err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("Server started", { port: address.port });
|
||||
|
||||
const handle: UiServerHandle = {
|
||||
url: `http://localhost:${address.port}/?session=${sessionToken}`,
|
||||
port: address.port,
|
||||
sessionToken,
|
||||
serverName: options.serverName,
|
||||
toolName: options.toolName,
|
||||
close: (reason?: string) => {
|
||||
markCompleted(reason ?? "closed");
|
||||
try {
|
||||
server.close();
|
||||
} catch {}
|
||||
closeSse();
|
||||
},
|
||||
sendToolInput: (args: Record<string, unknown>) => {
|
||||
pushEvent("tool-input", { arguments: args });
|
||||
},
|
||||
sendToolResult: (result: CallToolResult) => {
|
||||
pushEvent("tool-result", result);
|
||||
},
|
||||
sendResultPatch: (result: CallToolResult) => {
|
||||
pushEvent("result-patch", result);
|
||||
},
|
||||
sendToolCancelled: (reason: string) => {
|
||||
pushEvent("tool-cancelled", { reason });
|
||||
},
|
||||
sendHostContext: (context: UiHostContext) => {
|
||||
Object.assign(hostContext, context);
|
||||
pushEvent("host-context", context);
|
||||
},
|
||||
getSessionMessages: () => ({ ...sessionMessages }),
|
||||
getStreamSummary: () => streamSummary ? { ...streamSummary, phases: [...streamSummary.phases] } : undefined,
|
||||
};
|
||||
|
||||
resolve(handle);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function parseBody(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<UiProxyRequestBody<Record<string, unknown>> | null> {
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
if (!body || typeof body !== "object") {
|
||||
sendJson(res, 400, { ok: false, error: "Invalid request body" });
|
||||
return null;
|
||||
}
|
||||
return body as UiProxyRequestBody<Record<string, unknown>>;
|
||||
} catch (error) {
|
||||
sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : "Invalid body" });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_SIZE) {
|
||||
req.destroy();
|
||||
reject(new Error("Request body too large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function validateTokenQuery(url: URL, expected: string, res: ServerResponse): boolean {
|
||||
const token = url.searchParams.get("session");
|
||||
if (token !== expected) {
|
||||
sendJson(res, 403, { ok: false, error: "Invalid session" });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateTokenBody(
|
||||
body: UiProxyRequestBody<Record<string, unknown>>,
|
||||
expected: string,
|
||||
res: ServerResponse,
|
||||
): boolean {
|
||||
if (body.token !== expected) {
|
||||
sendJson(res, 403, { ok: false, error: "Invalid session" });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendJson<T>(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
payload: UiProxyResult<T>,
|
||||
): void {
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
385
agent/extensions/pi-mcp-adapter/ui-session.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { UrlElicitationRequiredError, type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { McpExtensionState } from "./state.ts";
|
||||
import {
|
||||
extractUiPromptText,
|
||||
UI_STREAM_HOST_CONTEXT_KEY,
|
||||
UI_STREAM_REQUEST_META_KEY,
|
||||
UI_STREAM_STRUCTURED_CONTENT_KEY,
|
||||
type UiHostContext,
|
||||
type UiMessageParams,
|
||||
type UiModelContextParams,
|
||||
type UiStreamMode,
|
||||
} from "./types.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { startUiServer, type UiServerHandle } from "./ui-server.ts";
|
||||
import { isGlimpseAvailable, openGlimpseWindow } from "./glimpse-ui.ts";
|
||||
|
||||
let activeGlimpseWindow: { close(): void } | null = null;
|
||||
|
||||
export interface UiSessionRequest {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
uiResourceUri: string;
|
||||
streamMode?: UiStreamMode;
|
||||
}
|
||||
|
||||
export interface UiSessionRuntime {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
reused: boolean;
|
||||
streamId?: string;
|
||||
streamToken?: string;
|
||||
streamMode?: UiStreamMode;
|
||||
requestMeta?: Record<string, unknown>;
|
||||
url: string;
|
||||
isActive: () => boolean;
|
||||
sendToolResult: (result: CallToolResult) => void;
|
||||
sendResultPatch: (result: CallToolResult) => void;
|
||||
sendToolCancelled: (reason: string) => void;
|
||||
close: (reason?: string) => void;
|
||||
}
|
||||
|
||||
const MAX_COMPLETED_SESSIONS = 10;
|
||||
|
||||
function withStreamEnvelope(
|
||||
result: CallToolResult,
|
||||
streamId: string | undefined,
|
||||
sequence: number,
|
||||
): CallToolResult {
|
||||
if (!streamId) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const structuredContent = result.structuredContent && typeof result.structuredContent === "object" && !Array.isArray(result.structuredContent)
|
||||
? { ...result.structuredContent }
|
||||
: {};
|
||||
|
||||
const rawEnvelope = structuredContent[UI_STREAM_STRUCTURED_CONTENT_KEY];
|
||||
const envelope = rawEnvelope && typeof rawEnvelope === "object" && !Array.isArray(rawEnvelope)
|
||||
? { ...rawEnvelope as Record<string, unknown> }
|
||||
: {
|
||||
frameType: "final",
|
||||
phase: "settled",
|
||||
status: result.isError ? "error" : "ok",
|
||||
};
|
||||
|
||||
structuredContent[UI_STREAM_STRUCTURED_CONTENT_KEY] = {
|
||||
...envelope,
|
||||
streamId,
|
||||
sequence,
|
||||
};
|
||||
|
||||
return {
|
||||
...result,
|
||||
structuredContent,
|
||||
};
|
||||
}
|
||||
|
||||
async function openInBrowser(state: McpExtensionState, url: string): Promise<void> {
|
||||
try {
|
||||
await state.openBrowser(url);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
state.ui?.notify(`MCP UI browser open failed: ${message}`, "warning");
|
||||
state.ui?.notify(`Open manually: ${url}`, "info");
|
||||
}
|
||||
}
|
||||
|
||||
export async function maybeStartUiSession(
|
||||
state: McpExtensionState,
|
||||
request: UiSessionRequest,
|
||||
): Promise<UiSessionRuntime | null> {
|
||||
const log = logger.child({
|
||||
component: "UiSession",
|
||||
server: request.serverName,
|
||||
tool: request.toolName,
|
||||
});
|
||||
|
||||
try {
|
||||
if (
|
||||
state.uiServer &&
|
||||
state.uiServer.serverName === request.serverName &&
|
||||
state.uiServer.toolName === request.toolName
|
||||
) {
|
||||
const existingHandle = state.uiServer;
|
||||
const streamMode = request.streamMode;
|
||||
const streamId = streamMode ? randomUUID() : undefined;
|
||||
const streamToken = streamMode ? randomUUID() : undefined;
|
||||
let active = true;
|
||||
let nextStreamSequence = 0;
|
||||
|
||||
const cleanupStreamListener = () => {
|
||||
if (streamToken) {
|
||||
state.manager.removeUiStreamListener(streamToken);
|
||||
}
|
||||
};
|
||||
|
||||
existingHandle.sendToolInput(request.toolArgs);
|
||||
|
||||
if (streamToken) {
|
||||
state.manager.registerUiStreamListener(streamToken, (serverName, notification) => {
|
||||
if (!active || state.uiServer !== existingHandle) return;
|
||||
if (serverName !== request.serverName) return;
|
||||
nextStreamSequence += 1;
|
||||
existingHandle.sendResultPatch(
|
||||
withStreamEnvelope(notification.result as CallToolResult, streamId, nextStreamSequence),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
serverName: request.serverName,
|
||||
toolName: request.toolName,
|
||||
reused: true,
|
||||
streamId,
|
||||
streamToken,
|
||||
streamMode,
|
||||
requestMeta: streamToken ? { [UI_STREAM_REQUEST_META_KEY]: streamToken } : undefined,
|
||||
url: existingHandle.url,
|
||||
isActive: () => active && state.uiServer === existingHandle,
|
||||
sendToolResult: (result: CallToolResult) => {
|
||||
if (!active || state.uiServer !== existingHandle) return;
|
||||
nextStreamSequence += 1;
|
||||
existingHandle.sendToolResult(withStreamEnvelope(result, streamId, nextStreamSequence));
|
||||
},
|
||||
sendResultPatch: (result: CallToolResult) => {
|
||||
if (!active || state.uiServer !== existingHandle) return;
|
||||
nextStreamSequence += 1;
|
||||
existingHandle.sendResultPatch(withStreamEnvelope(result, streamId, nextStreamSequence));
|
||||
},
|
||||
sendToolCancelled: (reason: string) => {
|
||||
if (!active || state.uiServer !== existingHandle) return;
|
||||
nextStreamSequence += 1;
|
||||
existingHandle.sendToolResult(
|
||||
withStreamEnvelope(
|
||||
{
|
||||
isError: true,
|
||||
content: [{ type: "text", text: reason }],
|
||||
},
|
||||
streamId,
|
||||
nextStreamSequence,
|
||||
),
|
||||
);
|
||||
},
|
||||
close: () => {
|
||||
active = false;
|
||||
cleanupStreamListener();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const resource = await state.uiResourceHandler.readUiResource(request.serverName, request.uiResourceUri);
|
||||
|
||||
if (state.uiServer) {
|
||||
state.uiServer.close("replaced");
|
||||
state.uiServer = null;
|
||||
}
|
||||
if (activeGlimpseWindow) {
|
||||
activeGlimpseWindow.close();
|
||||
activeGlimpseWindow = null;
|
||||
}
|
||||
|
||||
const streamMode = request.streamMode;
|
||||
const streamId = streamMode ? randomUUID() : undefined;
|
||||
const streamToken = streamMode ? randomUUID() : undefined;
|
||||
const hostContext: UiHostContext | undefined = streamMode && streamId
|
||||
? {
|
||||
[UI_STREAM_HOST_CONTEXT_KEY]: {
|
||||
mode: streamMode,
|
||||
streamId,
|
||||
intermediateResultPatches: streamMode === "stream-first",
|
||||
partialInput: false,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let active = true;
|
||||
let nextStreamSequence = 0;
|
||||
let handle: UiServerHandle | null = null;
|
||||
|
||||
const cleanupStreamListener = () => {
|
||||
if (streamToken) {
|
||||
state.manager.removeUiStreamListener(streamToken);
|
||||
}
|
||||
};
|
||||
|
||||
handle = await startUiServer({
|
||||
serverName: request.serverName,
|
||||
toolName: request.toolName,
|
||||
toolArgs: streamMode === "stream-first" ? {} : request.toolArgs,
|
||||
resource,
|
||||
manager: state.manager,
|
||||
consentManager: state.consentManager,
|
||||
hostContext,
|
||||
|
||||
onMessage: (params: UiMessageParams) => {
|
||||
const prompt = extractUiPromptText(params);
|
||||
if (prompt) {
|
||||
if (state.sendMessage) {
|
||||
state.sendMessage(
|
||||
{
|
||||
customType: "mcp-ui-prompt",
|
||||
content: [{ type: "text", text: `User sent prompt from ${request.serverName} UI: "${prompt}"` }],
|
||||
display: `💬 UI Prompt: ${prompt}`,
|
||||
details: { server: request.serverName, tool: request.toolName, prompt },
|
||||
},
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
log.debug("Triggered agent turn for UI prompt", { prompt: prompt.slice(0, 50) });
|
||||
}
|
||||
} else if (params.type === "intent" || params.intent) {
|
||||
const intent = params.intent ?? "";
|
||||
const intentParams = params.params;
|
||||
if (intent && state.sendMessage) {
|
||||
const paramsStr = intentParams ? ` ${JSON.stringify(intentParams)}` : "";
|
||||
state.sendMessage(
|
||||
{
|
||||
customType: "mcp-ui-intent",
|
||||
content: [{ type: "text", text: `User triggered intent from ${request.serverName} UI: ${intent}${paramsStr}` }],
|
||||
display: `🎯 UI Intent: ${intent}`,
|
||||
details: { server: request.serverName, tool: request.toolName, intent, params: intentParams },
|
||||
},
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
log.debug("Triggered agent turn for UI intent", { intent });
|
||||
}
|
||||
} else if (params.type === "notify" || params.message) {
|
||||
const text = params.message ?? "";
|
||||
if (text && state.ui) {
|
||||
state.ui.notify(`[${request.serverName}] ${text}`, "info");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onContextUpdate: (params: UiModelContextParams) => {
|
||||
log.debug("Model context update from UI", {
|
||||
hasContent: !!params.content,
|
||||
hasStructured: !!params.structuredContent,
|
||||
});
|
||||
},
|
||||
|
||||
onComplete: (reason: string) => {
|
||||
active = false;
|
||||
cleanupStreamListener();
|
||||
|
||||
if (state.uiServer === handle) {
|
||||
const messages = handle.getSessionMessages();
|
||||
const stream = handle.getStreamSummary();
|
||||
const hasContent =
|
||||
messages.prompts.length > 0 ||
|
||||
messages.intents.length > 0 ||
|
||||
messages.notifications.length > 0 ||
|
||||
!!stream;
|
||||
|
||||
if (hasContent) {
|
||||
state.completedUiSessions.push({
|
||||
serverName: handle.serverName,
|
||||
toolName: handle.toolName,
|
||||
completedAt: new Date(),
|
||||
reason,
|
||||
messages,
|
||||
stream,
|
||||
});
|
||||
|
||||
while (state.completedUiSessions.length > MAX_COMPLETED_SESSIONS) {
|
||||
state.completedUiSessions.shift();
|
||||
}
|
||||
|
||||
log.debug("Session completed", {
|
||||
reason,
|
||||
prompts: messages.prompts.length,
|
||||
intents: messages.intents.length,
|
||||
notifications: messages.notifications.length,
|
||||
streamFrames: stream?.frames ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
state.uiServer = null;
|
||||
if (activeGlimpseWindow) {
|
||||
activeGlimpseWindow.close();
|
||||
activeGlimpseWindow = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (streamToken) {
|
||||
state.manager.registerUiStreamListener(streamToken, (serverName, notification) => {
|
||||
if (!active || state.uiServer !== handle) return;
|
||||
if (serverName !== request.serverName) return;
|
||||
nextStreamSequence += 1;
|
||||
handle.sendResultPatch(withStreamEnvelope(notification.result as CallToolResult, streamId, nextStreamSequence));
|
||||
});
|
||||
}
|
||||
|
||||
state.uiServer = handle;
|
||||
|
||||
const glimpseDetected = isGlimpseAvailable();
|
||||
const viewerPref = process.env.MCP_UI_VIEWER?.toLowerCase();
|
||||
const useGlimpse = viewerPref === "glimpse" ||
|
||||
(viewerPref !== "browser" && glimpseDetected);
|
||||
|
||||
if (useGlimpse) {
|
||||
try {
|
||||
const glimpseHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{margin:0;padding:0;width:100vw;height:100vh;overflow:hidden}iframe{width:100%;height:100%;border:none}</style></head><body><iframe src="${handle.url}"></iframe></body></html>`;
|
||||
activeGlimpseWindow = await openGlimpseWindow(glimpseHtml, {
|
||||
title: `MCP · ${request.serverName} · ${request.toolName}`,
|
||||
width: 1000,
|
||||
height: 800,
|
||||
onClosed: () => {
|
||||
if (active) handle.close("glimpse-closed");
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.debug("Glimpse unavailable, using browser", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await openInBrowser(state, handle.url);
|
||||
}
|
||||
} else {
|
||||
await openInBrowser(state, handle.url);
|
||||
}
|
||||
|
||||
return {
|
||||
serverName: request.serverName,
|
||||
toolName: request.toolName,
|
||||
reused: false,
|
||||
streamId,
|
||||
streamToken,
|
||||
streamMode,
|
||||
requestMeta: streamToken ? { [UI_STREAM_REQUEST_META_KEY]: streamToken } : undefined,
|
||||
url: handle.url,
|
||||
isActive: () => active && state.uiServer === handle,
|
||||
sendToolResult: (result: CallToolResult) => {
|
||||
if (!active || state.uiServer !== handle) return;
|
||||
nextStreamSequence += 1;
|
||||
handle.sendToolResult(withStreamEnvelope(result, streamId, nextStreamSequence));
|
||||
},
|
||||
sendResultPatch: (result: CallToolResult) => {
|
||||
if (!active || state.uiServer !== handle) return;
|
||||
nextStreamSequence += 1;
|
||||
handle.sendResultPatch(withStreamEnvelope(result, streamId, nextStreamSequence));
|
||||
},
|
||||
sendToolCancelled: (reason: string) => {
|
||||
if (!active || state.uiServer !== handle) return;
|
||||
handle.sendToolCancelled(reason);
|
||||
},
|
||||
close: (reason?: string) => {
|
||||
active = false;
|
||||
cleanupStreamListener();
|
||||
handle.close(reason);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UrlElicitationRequiredError) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error("Failed to start UI session", error instanceof Error ? error : undefined);
|
||||
state.ui?.notify(
|
||||
`MCP UI unavailable for ${request.toolName} (${request.serverName}): ${message}`,
|
||||
"warning",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
89
agent/extensions/pi-mcp-adapter/ui-stream-types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const UI_STREAM_HOST_CONTEXT_KEY = "pi-mcp-adapter/stream";
|
||||
export const UI_STREAM_REQUEST_META_KEY = "pi-mcp-adapter/stream-token";
|
||||
export const UI_STREAM_RESULT_PATCH_METHOD = "notifications/pi-mcp-adapter/ui-result-patch";
|
||||
export const SERVER_STREAM_RESULT_PATCH_METHOD = "notifications/pi-mcp-adapter/result-patch";
|
||||
export const UI_STREAM_STRUCTURED_CONTENT_KEY = "pi-mcp-adapter/stream";
|
||||
|
||||
export const uiStreamModeSchema = z.enum(["eager", "stream-first"]);
|
||||
export type UiStreamMode = z.infer<typeof uiStreamModeSchema>;
|
||||
|
||||
export const visualizationStreamPhaseSchema = z.enum(["shell", "narrative", "structure", "detail", "settled"]);
|
||||
export type VisualizationStreamPhase = z.infer<typeof visualizationStreamPhaseSchema>;
|
||||
|
||||
export const visualizationStreamFrameTypeSchema = z.enum(["patch", "checkpoint", "final"]);
|
||||
export type VisualizationStreamFrameType = z.infer<typeof visualizationStreamFrameTypeSchema>;
|
||||
|
||||
export const visualizationStreamStatusSchema = z.enum(["ok", "error"]);
|
||||
export type VisualizationStreamStatus = z.infer<typeof visualizationStreamStatusSchema>;
|
||||
|
||||
const looseRecordSchema = z.record(z.string(), z.unknown());
|
||||
const looseArraySchema = z.array(z.unknown());
|
||||
|
||||
export const uiStreamHostContextSchema = z.object({
|
||||
mode: uiStreamModeSchema,
|
||||
streamId: z.string().min(1),
|
||||
intermediateResultPatches: z.boolean(),
|
||||
partialInput: z.boolean(),
|
||||
});
|
||||
export type UiStreamHostContext = z.infer<typeof uiStreamHostContextSchema>;
|
||||
|
||||
export const visualizationStreamEnvelopeSchema = z.object({
|
||||
streamId: z.string().min(1),
|
||||
sequence: z.number().int().nonnegative(),
|
||||
frameType: visualizationStreamFrameTypeSchema,
|
||||
phase: visualizationStreamPhaseSchema,
|
||||
status: visualizationStreamStatusSchema,
|
||||
message: z.string().optional(),
|
||||
spec: looseRecordSchema.optional(),
|
||||
checkpoint: looseRecordSchema.optional(),
|
||||
});
|
||||
export type VisualizationStreamEnvelope = z.infer<typeof visualizationStreamEnvelopeSchema>;
|
||||
|
||||
export const uiStreamCallToolResultSchema = z.object({
|
||||
content: looseArraySchema.optional(),
|
||||
structuredContent: looseRecordSchema.optional(),
|
||||
isError: z.boolean().optional(),
|
||||
_meta: looseRecordSchema.optional(),
|
||||
}).passthrough();
|
||||
export type UiStreamCallToolResult = z.infer<typeof uiStreamCallToolResultSchema>;
|
||||
|
||||
export const uiStreamResultPatchNotificationSchema = z.object({
|
||||
method: z.literal(UI_STREAM_RESULT_PATCH_METHOD),
|
||||
params: uiStreamCallToolResultSchema,
|
||||
});
|
||||
export type UiStreamResultPatchNotification = z.infer<typeof uiStreamResultPatchNotificationSchema>;
|
||||
|
||||
export const serverStreamResultPatchNotificationSchema = z.object({
|
||||
method: z.literal(SERVER_STREAM_RESULT_PATCH_METHOD),
|
||||
params: z.object({
|
||||
streamToken: z.string().min(1),
|
||||
result: uiStreamCallToolResultSchema,
|
||||
}),
|
||||
});
|
||||
export type ServerStreamResultPatchNotification = z.infer<typeof serverStreamResultPatchNotificationSchema>;
|
||||
|
||||
export interface UiStreamSummary {
|
||||
streamId: string;
|
||||
mode: UiStreamMode;
|
||||
frames: number;
|
||||
phases: VisualizationStreamPhase[];
|
||||
finalStatus?: VisualizationStreamStatus;
|
||||
lastMessage?: string;
|
||||
}
|
||||
|
||||
export function getUiStreamHostContext(hostContext: Record<string, unknown> | undefined): UiStreamHostContext | undefined {
|
||||
const candidate = hostContext?.[UI_STREAM_HOST_CONTEXT_KEY];
|
||||
const parsed = uiStreamHostContextSchema.safeParse(candidate);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
export function getVisualizationStreamEnvelope(structuredContent: unknown): VisualizationStreamEnvelope | undefined {
|
||||
if (!structuredContent || typeof structuredContent !== "object" || Array.isArray(structuredContent)) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = (structuredContent as Record<string, unknown>)[UI_STREAM_STRUCTURED_CONTENT_KEY];
|
||||
const parsed = visualizationStreamEnvelopeSchema.safeParse(candidate);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
129
agent/extensions/pi-mcp-adapter/utils.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { McpConfig, ServerEntry } from "./types.ts";
|
||||
|
||||
async function execOpen(pi: ExtensionAPI, target: string, browser?: string) {
|
||||
const os = platform();
|
||||
|
||||
if (os === "darwin") {
|
||||
return browser ? pi.exec("open", ["-a", browser, target]) : pi.exec("open", [target]);
|
||||
}
|
||||
if (os === "win32") {
|
||||
return browser
|
||||
? pi.exec("cmd", ["/c", "start", "", browser, target])
|
||||
: pi.exec("cmd", ["/c", "start", "", target]);
|
||||
}
|
||||
return browser ? pi.exec(browser, [target]) : pi.exec("xdg-open", [target]);
|
||||
}
|
||||
|
||||
export async function openUrl(pi: ExtensionAPI, url: string, browser?: string): Promise<void> {
|
||||
const result = await execOpen(pi, url, browser);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || `Failed to open browser (exit code ${result.code})`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openPath(pi: ExtensionAPI, targetPath: string): Promise<void> {
|
||||
const result = await execOpen(pi, targetPath);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || `Failed to open path (exit code ${result.code})`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function parallelLimit<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = [];
|
||||
let index = 0;
|
||||
|
||||
async function worker() {
|
||||
while (index < items.length) {
|
||||
const i = index++;
|
||||
results[i] = await fn(items[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array(Math.min(limit, items.length)).fill(null).map(() => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
export function getConfigPathFromArgv(): string | undefined {
|
||||
const idx = process.argv.indexOf("--mcp-config");
|
||||
if (idx >= 0 && idx + 1 < process.argv.length) {
|
||||
return process.argv[idx + 1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function interpolateEnvVars(value: string): string {
|
||||
return value
|
||||
.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "")
|
||||
.replace(/\$env:(\w+)/g, (_, name) => process.env[name] ?? "");
|
||||
}
|
||||
|
||||
export function interpolateEnvRecord(values: Record<string, string> | undefined): Record<string, string> | undefined {
|
||||
if (!values) return undefined;
|
||||
|
||||
const resolved: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
resolved[key] = interpolateEnvVars(value);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function resolveConfigPath(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
|
||||
const resolved = interpolateEnvVars(value);
|
||||
if (resolved === "~") return homedir();
|
||||
if (resolved.startsWith("~/") || resolved.startsWith("~\\")) {
|
||||
return join(homedir(), resolved.slice(2));
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function resolveBearerToken(definition: Pick<ServerEntry, "bearerToken" | "bearerTokenEnv">): string | undefined {
|
||||
if (definition.bearerToken !== undefined) {
|
||||
return interpolateEnvVars(definition.bearerToken);
|
||||
}
|
||||
return definition.bearerTokenEnv ? process.env[definition.bearerTokenEnv] : undefined;
|
||||
}
|
||||
|
||||
export function truncateAtWord(text: string, target: number): string {
|
||||
if (!text || text.length <= target) return text;
|
||||
|
||||
const truncated = text.slice(0, target);
|
||||
const lastSpace = truncated.lastIndexOf(" ");
|
||||
|
||||
if (lastSpace > target * 0.6) {
|
||||
return truncated.slice(0, lastSpace) + "...";
|
||||
}
|
||||
|
||||
return truncated + "...";
|
||||
}
|
||||
|
||||
export function formatAuthRequiredMessage(
|
||||
config: Pick<McpConfig, "settings">,
|
||||
serverName: string,
|
||||
defaultMessage: string,
|
||||
): string {
|
||||
const template = config.settings?.authRequiredMessage;
|
||||
return template ? template.replaceAll("${server}", serverName) : defaultMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the adapter-owned UI stream mode from tool metadata.
|
||||
*/
|
||||
export function extractToolUiStreamMode(toolMeta: Record<string, unknown> | undefined): "eager" | "stream-first" | undefined {
|
||||
const uiMeta = toolMeta?.ui;
|
||||
if (!uiMeta || typeof uiMeta !== "object") return undefined;
|
||||
const streamMode = (uiMeta as Record<string, unknown>)["pi-mcp-adapter.streamMode"];
|
||||
if (streamMode === "eager" || streamMode === "stream-first") {
|
||||
return streamMode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
14
agent/extensions/pi-mcp-adapter/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["__tests__/**/*.test.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["*.ts"],
|
||||
exclude: ["__tests__/**", "vitest.config.ts", "cli.js"],
|
||||
},
|
||||
},
|
||||
});
|
||||
186
agent/extensions/sproutclaw-setup/index.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* sproutclaw / mengya 命令安装扩展
|
||||
*
|
||||
* 在 Linux 和 Windows 上分别创建/更新以下命令:
|
||||
* - mengya:源码版(Linux: ./test.sh,Windows: test.bat)
|
||||
* - sproutclaw:构建版(Linux: ./build.sh,Windows: build.bat)
|
||||
*
|
||||
* 不再安装 `pi` 命令,避免与原版 pi 冲突。
|
||||
*
|
||||
* Linux 命令安装到 /usr/local/bin/。
|
||||
* Windows 批处理文件创建在项目根目录,需要把项目根目录加入 PATH 后全局使用。
|
||||
*
|
||||
* 命令 /install-commands 可随时手动重装。
|
||||
*/
|
||||
|
||||
import { existsSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const extensionDir = typeof __dirname !== "undefined" ? __dirname : dirname(fileURLToPath(import.meta.url));
|
||||
const SPROUTCLAW_DIR = dirname(dirname(dirname(dirname(extensionDir))));
|
||||
const LINUX_BIN_DIR = "/usr/local/bin";
|
||||
|
||||
function linuxMengyaScript(): string {
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SPROUTCLAW_DIR="${SPROUTCLAW_DIR}"
|
||||
exec "$SPROUTCLAW_DIR/test.sh" "$@"
|
||||
`;
|
||||
}
|
||||
|
||||
function linuxSproutclawScript(): string {
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SPROUTCLAW_DIR="${SPROUTCLAW_DIR}"
|
||||
BUILD_SH="$SPROUTCLAW_DIR/build.sh"
|
||||
|
||||
case "\${1:-}" in
|
||||
\tbuild)
|
||||
\t\tshift
|
||||
\t\tcd "$SPROUTCLAW_DIR"
|
||||
\t\texec npm run build "$@"
|
||||
\t\t;;
|
||||
\trun)
|
||||
\t\tshift
|
||||
\t\texec "$BUILD_SH" "$@"
|
||||
\t\t;;
|
||||
\thelp|-h|--help)
|
||||
\t\tcat <<'EOF'
|
||||
sproutclaw - SproutClaw 构建版启动器
|
||||
|
||||
用法:
|
||||
sproutclaw 启动 SproutClaw(构建版)
|
||||
sproutclaw run [args] 同 sproutclaw
|
||||
sproutclaw build 从源码构建所有包
|
||||
|
||||
在工作目录中启动;agent 配置仍使用安装目录下的 .sproutclaw/agent。
|
||||
其余参数透传给 SproutClaw,例如: sproutclaw --version
|
||||
EOF
|
||||
\t\t;;
|
||||
\t*)
|
||||
\t\texec "$BUILD_SH" "$@"
|
||||
\t\t;;
|
||||
esac
|
||||
`;
|
||||
}
|
||||
|
||||
function windowsMengyaBat(): string {
|
||||
return `@echo off
|
||||
setlocal
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
if "%SCRIPT_DIR:~-1%"=="\\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
|
||||
if not defined SPROUTCLAW_CODING_AGENT_DIR (
|
||||
set "SPROUTCLAW_CODING_AGENT_DIR=%SCRIPT_DIR%\\.sproutclaw\\agent"
|
||||
)
|
||||
call "%SCRIPT_DIR%\\test.bat" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
`;
|
||||
}
|
||||
|
||||
function windowsSproutclawBat(): string {
|
||||
return `@echo off
|
||||
setlocal
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
if "%SCRIPT_DIR:~-1%"=="\\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
|
||||
if not defined SPROUTCLAW_CODING_AGENT_DIR (
|
||||
set "SPROUTCLAW_CODING_AGENT_DIR=%SCRIPT_DIR%\\.sproutclaw\\agent"
|
||||
)
|
||||
|
||||
if "%~1"=="" goto run
|
||||
if /I "%~1"=="run" (
|
||||
shift
|
||||
goto run
|
||||
)
|
||||
if /I "%~1"=="build" (
|
||||
shift
|
||||
npm run build %*
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
if /I "%~1"=="help" goto help
|
||||
if "%~1"=="-h" goto help
|
||||
if "%~1"=="--help" goto help
|
||||
|
||||
:run
|
||||
call "%SCRIPT_DIR%\\build.bat" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
:help
|
||||
echo sproutclaw - SproutClaw 构建版启动器
|
||||
echo.
|
||||
echo 用法:
|
||||
echo sproutclaw 启动 SproutClaw(构建版)
|
||||
echo sproutclaw run [args] 同 sproutclaw
|
||||
echo sproutclaw build 从源码构建所有包
|
||||
echo.
|
||||
echo 其余参数透传给 SproutClaw,例如: sproutclaw --version
|
||||
exit /b 0
|
||||
`;
|
||||
}
|
||||
|
||||
function writeIfChanged(path: string, content: string, mode?: number): boolean {
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const current = readFileSync(path, "utf-8");
|
||||
if (current === content) return false;
|
||||
} catch {
|
||||
// Rewrite unreadable or invalid files below.
|
||||
}
|
||||
}
|
||||
writeFileSync(path, content, "utf-8");
|
||||
if (mode !== undefined) chmodSync(path, mode);
|
||||
return true;
|
||||
}
|
||||
|
||||
function installLinuxCommands(): string[] {
|
||||
const created: string[] = [];
|
||||
if (writeIfChanged(join(LINUX_BIN_DIR, "mengya"), linuxMengyaScript(), 0o755)) {
|
||||
created.push("/usr/local/bin/mengya");
|
||||
}
|
||||
if (writeIfChanged(join(LINUX_BIN_DIR, "sproutclaw"), linuxSproutclawScript(), 0o755)) {
|
||||
created.push("/usr/local/bin/sproutclaw");
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
function installWindowsCommands(): string[] {
|
||||
const created: string[] = [];
|
||||
if (writeIfChanged(join(SPROUTCLAW_DIR, "mengya.bat"), windowsMengyaBat())) {
|
||||
created.push(join(SPROUTCLAW_DIR, "mengya.bat"));
|
||||
}
|
||||
if (writeIfChanged(join(SPROUTCLAW_DIR, "sproutclaw.bat"), windowsSproutclawBat())) {
|
||||
created.push(join(SPROUTCLAW_DIR, "sproutclaw.bat"));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
function install(): string[] {
|
||||
return isWindows ? installWindowsCommands() : installLinuxCommands();
|
||||
}
|
||||
|
||||
const created = install();
|
||||
for (const path of created) {
|
||||
console.log(`[sproutclaw-setup] Created/updated ${path}`);
|
||||
}
|
||||
|
||||
pi.registerCommand("install-commands", {
|
||||
description: isWindows
|
||||
? "Install mengya.bat (source) and sproutclaw.bat (built) to the repo root"
|
||||
: "Install mengya (source) and sproutclaw (built) to /usr/local/bin",
|
||||
handler: async (_args, ctx) => {
|
||||
const paths = install();
|
||||
if (paths.length > 0) {
|
||||
ctx.ui.notify(`Installed/updated ${paths.length} command(s):\n${paths.join("\n")}`, "success");
|
||||
} else {
|
||||
ctx.ui.notify("Commands already up to date", "info");
|
||||
}
|
||||
if (isWindows) {
|
||||
ctx.ui.notify(`请把项目目录加入 PATH 以全局使用:\n${SPROUTCLAW_DIR}`, "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
172
agent/extensions/startup-chinese/index.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 启动界面中文翻译扩展
|
||||
*
|
||||
* 将 pi 的初始启动界面中的介绍和引导文字翻译为中文,
|
||||
* 快捷键和命令提示保持原样。
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { keyHint, keyText, rawKeyHint, VERSION } from "@earendil-works/pi-coding-agent";
|
||||
import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
||||
|
||||
const SPROUTCLAW_LOGO = [
|
||||
"███████╗██████╗ ██████╗ ██████╗ ██╗ ██╗████████╗ ██████╗██╗ █████╗ ██╗ ██╗",
|
||||
"██╔════╝██╔══██╗██╔══██╗██╔═══██╗██║ ██║╚══██╔══╝██╔════╝██║ ██╔══██╗██║ ██║",
|
||||
"███████╗██████╔╝██████╔╝██║ ██║██║ ██║ ██║ ██║ ██║ ███████║██║ █╗ ██║",
|
||||
"╚════██║██╔═══╝ ██╔══██╗██║ ██║██║ ██║ ██║ ██║ ██║ ██╔══██║██║███╗██║",
|
||||
"███████║██║ ██║ ██║╚██████╔╝╚██████╔╝ ██║ ╚██████╗███████╗██║ ██║╚███╔███╔╝",
|
||||
"╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ",
|
||||
];
|
||||
|
||||
const LOGO_PALETTE = [
|
||||
[125, 255, 230],
|
||||
[154, 255, 140],
|
||||
[255, 245, 135],
|
||||
[255, 176, 235],
|
||||
[150, 210, 255],
|
||||
[190, 170, 255],
|
||||
];
|
||||
|
||||
function rgb(text: string, color: number[]): string {
|
||||
return `\x1b[38;2;${color[0]};${color[1]};${color[2]}m${text}\x1b[39m`;
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return Math.round(a + (b - a) * t);
|
||||
}
|
||||
|
||||
function gradientText(text: string, offset = 0): string {
|
||||
const chars = [...text];
|
||||
const last = Math.max(1, chars.length - 1);
|
||||
return chars
|
||||
.map((char, index) => {
|
||||
if (char === " ") return char;
|
||||
const position = (index / last + offset) % 1;
|
||||
const scaled = position * (LOGO_PALETTE.length - 1);
|
||||
const startIndex = Math.floor(scaled);
|
||||
const endIndex = Math.min(LOGO_PALETTE.length - 1, startIndex + 1);
|
||||
const t = scaled - startIndex;
|
||||
const start = LOGO_PALETTE[startIndex];
|
||||
const end = LOGO_PALETTE[endIndex];
|
||||
return rgb(char, [
|
||||
lerp(start[0], end[0], t),
|
||||
lerp(start[1], end[1], t),
|
||||
lerp(start[2], end[2], t),
|
||||
]);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function startupLine(label: string, text: string, color: number[]): string {
|
||||
return `${rgb(label, color)} ${rgb(text, [210, 235, 255])}`;
|
||||
}
|
||||
|
||||
function keyTextOr(action: string, fallback: string): string {
|
||||
const text = keyText(action as any).trim();
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
function renderDivider(theme: { fg: (color: string, text: string) => string }, width: number): string {
|
||||
return theme.fg("dim", "─".repeat(Math.max(1, width)));
|
||||
}
|
||||
|
||||
function fitWidth(lines: string[], width: number): string[] {
|
||||
const result: string[] = [];
|
||||
for (const line of lines) {
|
||||
for (const segment of line.split("\n")) {
|
||||
for (const wrapped of wrapTextWithAnsi(segment, width)) {
|
||||
result.push(truncateToWidth(wrapped, width));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const WIDE_LOGO_MIN_WIDTH = 88;
|
||||
|
||||
function renderSproutclawLogo(theme: any, width: number): string[] {
|
||||
if (width >= WIDE_LOGO_MIN_WIDTH) {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < SPROUTCLAW_LOGO.length; i++) {
|
||||
lines.push(theme.bold(gradientText(SPROUTCLAW_LOGO[i], i * 0.08)));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
rgb("❯ ", [100, 200, 255]),
|
||||
theme.bold(gradientText("SproutClaw")),
|
||||
rgb("-萌芽Bot ", [190, 170, 255]),
|
||||
rgb(`v${VERSION}`, [170, 235, 255]),
|
||||
].join(""),
|
||||
];
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
ctx.ui.setHeader((_tui, theme) => {
|
||||
const expandedInstructions = [
|
||||
keyHint("app.interrupt", "中断当前任务"),
|
||||
keyHint("app.clear", "清空输入"),
|
||||
rawKeyHint(`${keyText("app.clear")} twice`, "退出"),
|
||||
keyHint("app.exit", "空输入退出"),
|
||||
keyHint("app.suspend", "挂起进程"),
|
||||
keyHint("tui.editor.deleteToLineEnd" as any, "删除到行尾"),
|
||||
keyHint("app.thinking.cycle", "切换思考强度"),
|
||||
rawKeyHint(
|
||||
`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`,
|
||||
"切换模型",
|
||||
),
|
||||
keyHint("app.model.select", "选择模型"),
|
||||
keyHint("app.tools.expand", "展开工具输出"),
|
||||
keyHint("app.thinking.toggle", "展开思考过程"),
|
||||
keyHint("app.editor.external", "外部编辑器"),
|
||||
rawKeyHint("/", "Agent 命令"),
|
||||
rawKeyHint("!", "执行 shell"),
|
||||
rawKeyHint("!!", "执行 shell 且不进上下文"),
|
||||
keyHint("app.message.followUp", "追加后续消息"),
|
||||
keyHint("app.message.dequeue", "编辑队列消息"),
|
||||
keyHint("app.clipboard.pasteImage", "粘贴图片"),
|
||||
rawKeyHint("drop files", "附加文件"),
|
||||
].join("\n");
|
||||
const compactOnboarding = startupLine(
|
||||
"工作台",
|
||||
`描述要处理的代码、部署或服务器问题,我会执行命令、修改文件并同步进度。按 ${keyTextOr("app.tools.expand", "Ctrl+O")} 展开详情。`,
|
||||
[150, 255, 210],
|
||||
);
|
||||
const onboarding = [
|
||||
startupLine("SproutClaw", "树萌芽运维开发助手,负责代码修改、服务部署、服务器操作、排障和知识检索。", [255, 232, 140]),
|
||||
rgb("直接输入任务即可开始。", [255, 190, 235]),
|
||||
].join("\n");
|
||||
|
||||
let expanded = false;
|
||||
|
||||
return {
|
||||
render(width: number): string[] {
|
||||
const divider = renderDivider(theme, width);
|
||||
const lines: string[] = [
|
||||
divider,
|
||||
...renderSproutclawLogo(theme, width),
|
||||
divider,
|
||||
rgb("萌芽运维开发Agent助手", [170, 235, 255]),
|
||||
];
|
||||
if (expanded) {
|
||||
lines.push(expandedInstructions);
|
||||
lines.push(onboarding);
|
||||
} else {
|
||||
lines.push(compactOnboarding);
|
||||
lines.push(onboarding);
|
||||
}
|
||||
return fitWidth(lines, width);
|
||||
},
|
||||
invalidate() {},
|
||||
setExpanded(v: boolean) {
|
||||
expanded = v;
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
40
agent/extensions/status-line/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Status Line Extension
|
||||
*
|
||||
* Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer.
|
||||
* Shows turn progress with themed colors.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let turnCount = 0;
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const theme = ctx.ui.theme;
|
||||
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
|
||||
});
|
||||
|
||||
pi.on("turn_start", async (_event, ctx) => {
|
||||
turnCount++;
|
||||
const theme = ctx.ui.theme;
|
||||
const spinner = theme.fg("accent", "●");
|
||||
const text = theme.fg("dim", ` Turn ${turnCount}...`);
|
||||
ctx.ui.setStatus("status-demo", spinner + text);
|
||||
});
|
||||
|
||||
pi.on("turn_end", async (_event, ctx) => {
|
||||
const theme = ctx.ui.theme;
|
||||
const check = theme.fg("success", "✓");
|
||||
const text = theme.fg("dim", ` Turn ${turnCount} complete`);
|
||||
ctx.ui.setStatus("status-demo", check + text);
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (event, ctx) => {
|
||||
if (event.reason === "new") {
|
||||
turnCount = 0;
|
||||
const theme = ctx.ui.theme;
|
||||
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
|
||||
}
|
||||
});
|
||||
}
|
||||
281
agent/extensions/system-info/index.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* System Info Extension
|
||||
*
|
||||
* Injects host environment details into the system prompt:
|
||||
* - OS (Windows / Linux / macOS)
|
||||
* - Terminal / shell environment
|
||||
* - Local IPv4 addresses
|
||||
* - Common programming language toolchains
|
||||
*
|
||||
* Auto-discovered from .sproutclaw/agent/extensions/system-info/
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import { basename } from "node:path";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
interface ToolchainEntry {
|
||||
label: string;
|
||||
version: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SystemInfoSnapshot {
|
||||
os: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
hostname: string;
|
||||
terminal: string;
|
||||
shell: string;
|
||||
ipAddresses: string[];
|
||||
toolchains: ToolchainEntry[];
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
const NULL = "NULL";
|
||||
|
||||
interface ToolchainProbe {
|
||||
label: string;
|
||||
bins: string[];
|
||||
args: string[];
|
||||
}
|
||||
|
||||
const TOOLCHAIN_PROBES: ToolchainProbe[] = [
|
||||
{ label: "Python", bins: ["python3", "python"], args: ["--version"] },
|
||||
{ label: "Java", bins: ["java"], args: ["-version"] },
|
||||
{ label: "Node.js", bins: ["node"], args: ["--version"] },
|
||||
{ label: "Bun", bins: ["bun"], args: ["--version"] },
|
||||
{ label: "Rust", bins: ["rustc"], args: ["--version"] },
|
||||
{ label: "Clang", bins: ["clang"], args: ["--version"] },
|
||||
{ label: "Golang", bins: ["go"], args: ["version"] },
|
||||
{ label: "GCC", bins: ["gcc"], args: ["--version"] },
|
||||
];
|
||||
|
||||
function isIPv4(family: string | number): boolean {
|
||||
return family === "IPv4" || family === 4;
|
||||
}
|
||||
|
||||
function getOsLabel(): string {
|
||||
switch (process.platform) {
|
||||
case "win32":
|
||||
return "Windows";
|
||||
case "linux":
|
||||
return "Linux";
|
||||
case "darwin":
|
||||
return "macOS";
|
||||
default:
|
||||
return process.platform;
|
||||
}
|
||||
}
|
||||
|
||||
function describeShell(): string {
|
||||
if (process.platform === "win32") {
|
||||
if (process.env.PSModulePath || /powershell/i.test(process.env.SHELL ?? "")) {
|
||||
return "PowerShell";
|
||||
}
|
||||
const comspec = process.env.ComSpec;
|
||||
return comspec ? basename(comspec) : "unknown";
|
||||
}
|
||||
|
||||
const shell = process.env.SHELL;
|
||||
return shell ? basename(shell) : "unknown";
|
||||
}
|
||||
|
||||
function describeTerminal(): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (process.env.TERM_PROGRAM) {
|
||||
const version = process.env.TERM_PROGRAM_VERSION;
|
||||
parts.push(version ? `${process.env.TERM_PROGRAM} ${version}` : process.env.TERM_PROGRAM);
|
||||
} else if (process.env.WT_SESSION) {
|
||||
parts.push("Windows Terminal");
|
||||
} else if (process.env.CURSOR_TRACE_ID || process.env.CURSOR_AGENT) {
|
||||
parts.push("Cursor");
|
||||
} else if (process.env.VSCODE_PID || process.env.TERM_PROGRAM === "vscode") {
|
||||
parts.push("VS Code Integrated Terminal");
|
||||
} else if (process.env.TERM) {
|
||||
parts.push(`TERM=${process.env.TERM}`);
|
||||
}
|
||||
|
||||
const shell = describeShell();
|
||||
if (shell !== "unknown") {
|
||||
parts.push(`shell=${shell}`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(", ") : "unknown";
|
||||
}
|
||||
|
||||
function getLocalIpAddresses(): string[] {
|
||||
const addresses: string[] = [];
|
||||
|
||||
for (const [interfaceName, entries] of Object.entries(os.networkInterfaces())) {
|
||||
if (!entries) continue;
|
||||
for (const entry of entries) {
|
||||
if (entry.internal || !isIPv4(entry.family)) continue;
|
||||
addresses.push(`${interfaceName}: ${entry.address}`);
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function findExecutable(candidates: string[]): string | null {
|
||||
for (const candidate of candidates) {
|
||||
if (process.platform === "win32") {
|
||||
const result = spawnSync("where.exe", [candidate], { encoding: "utf8", timeout: 5000 });
|
||||
const line = firstNonEmptyLine(result.stdout ?? "");
|
||||
if (result.status === 0 && line) {
|
||||
return line;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = spawnSync("sh", ["-lc", `command -v ${candidate} 2>/dev/null`], {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
});
|
||||
const line = firstNonEmptyLine(result.stdout ?? "");
|
||||
if (result.status === 0 && line) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function runVersionCommand(executable: string, args: string[]): string {
|
||||
const result = spawnSync(executable, args, {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const output = firstNonEmptyLine(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
|
||||
return output;
|
||||
}
|
||||
|
||||
function probeToolchain(probe: ToolchainProbe): ToolchainEntry {
|
||||
const executable = findExecutable(probe.bins);
|
||||
if (!executable) {
|
||||
return { label: probe.label, version: NULL, path: NULL };
|
||||
}
|
||||
|
||||
const version = runVersionCommand(executable, probe.args);
|
||||
return {
|
||||
label: probe.label,
|
||||
version: version || NULL,
|
||||
path: executable,
|
||||
};
|
||||
}
|
||||
|
||||
function collectToolchains(): ToolchainEntry[] {
|
||||
return TOOLCHAIN_PROBES.map((probe) => probeToolchain(probe));
|
||||
}
|
||||
|
||||
function collectSystemInfo(): SystemInfoSnapshot {
|
||||
return {
|
||||
os: getOsLabel(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
hostname: os.hostname(),
|
||||
terminal: describeTerminal(),
|
||||
shell: describeShell(),
|
||||
ipAddresses: getLocalIpAddresses(),
|
||||
toolchains: collectToolchains(),
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function formatToolchainLine(entry: ToolchainEntry): string {
|
||||
if (entry.version === NULL && entry.path === NULL) {
|
||||
return `- ${entry.label}: version=${NULL}, path=${NULL}`;
|
||||
}
|
||||
return `- ${entry.label}: version=${entry.version}, path=${entry.path}`;
|
||||
}
|
||||
|
||||
function formatSystemInfoSection(info: SystemInfoSnapshot): string {
|
||||
const ipLines =
|
||||
info.ipAddresses.length > 0
|
||||
? info.ipAddresses.map((address) => `- ${address}`).join("\n")
|
||||
: "- (no non-loopback IPv4 address found)";
|
||||
|
||||
const toolchainLines = info.toolchains.map((entry) => formatToolchainLine(entry)).join("\n");
|
||||
|
||||
return `
|
||||
## Host Environment
|
||||
|
||||
The following reflects the machine where SproutClaw is running. Use it when choosing commands, paths, or network assumptions.
|
||||
|
||||
- OS: ${info.os} (${info.platform}/${info.arch})
|
||||
- Hostname: ${info.hostname}
|
||||
- Terminal: ${info.terminal}
|
||||
- Shell: ${info.shell}
|
||||
- Local IPv4:
|
||||
${ipLines}
|
||||
- Toolchains:
|
||||
${toolchainLines}
|
||||
- Captured at: ${info.capturedAt}
|
||||
`.trimEnd();
|
||||
}
|
||||
|
||||
function formatSummary(info: SystemInfoSnapshot): string {
|
||||
const ips = info.ipAddresses.length > 0 ? info.ipAddresses.join(", ") : "none";
|
||||
const toolchainSummary = info.toolchains
|
||||
.map((entry) => {
|
||||
if (entry.version === NULL) {
|
||||
return `${entry.label}: ${NULL}`;
|
||||
}
|
||||
return `${entry.label}: ${entry.version}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
`OS: ${info.os}`,
|
||||
`Terminal: ${info.terminal}`,
|
||||
`Shell: ${info.shell}`,
|
||||
`IPv4: ${ips}`,
|
||||
"",
|
||||
toolchainSummary,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export default function systemInfoExtension(pi: ExtensionAPI) {
|
||||
let systemInfo = collectSystemInfo();
|
||||
|
||||
function refreshSystemInfo(): SystemInfoSnapshot {
|
||||
systemInfo = collectSystemInfo();
|
||||
return systemInfo;
|
||||
}
|
||||
|
||||
pi.on("session_start", async () => {
|
||||
refreshSystemInfo();
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async (event) => {
|
||||
const section = formatSystemInfoSection(systemInfo);
|
||||
if (event.systemPrompt.includes("## Host Environment")) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
systemPrompt: `${event.systemPrompt}\n\n${section}`,
|
||||
};
|
||||
});
|
||||
|
||||
pi.registerCommand("system-info", {
|
||||
description: "Show or refresh injected host environment details",
|
||||
handler: async (_args, ctx) => {
|
||||
const info = refreshSystemInfo();
|
||||
ctx.ui.notify(formatSummary(info), "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
47
agent/extensions/tps/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
function isAssistantMessage(message: unknown): message is AssistantMessage {
|
||||
if (!message || typeof message !== "object") return false;
|
||||
const role = (message as { role?: unknown }).role;
|
||||
return role === "assistant";
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let agentStartMs: number | null = null;
|
||||
|
||||
pi.on("agent_start", () => {
|
||||
agentStartMs = Date.now();
|
||||
});
|
||||
|
||||
pi.on("agent_end", (event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
if (agentStartMs === null) return;
|
||||
|
||||
const elapsedMs = Date.now() - agentStartMs;
|
||||
agentStartMs = null;
|
||||
if (elapsedMs <= 0) return;
|
||||
|
||||
let input = 0;
|
||||
let output = 0;
|
||||
let cacheRead = 0;
|
||||
let cacheWrite = 0;
|
||||
let totalTokens = 0;
|
||||
|
||||
for (const message of event.messages) {
|
||||
if (!isAssistantMessage(message)) continue;
|
||||
input += message.usage.input || 0;
|
||||
output += message.usage.output || 0;
|
||||
cacheRead += message.usage.cacheRead || 0;
|
||||
cacheWrite += message.usage.cacheWrite || 0;
|
||||
totalTokens += message.usage.totalTokens || 0;
|
||||
}
|
||||
|
||||
if (output <= 0) return;
|
||||
|
||||
const elapsedSeconds = elapsedMs / 1000;
|
||||
const tokensPerSecond = output / elapsedSeconds;
|
||||
const message = `TPS ${tokensPerSecond.toFixed(1)} tok/s. out ${output.toLocaleString()}, in ${input.toLocaleString()}, cache r/w ${cacheRead.toLocaleString()}/${cacheWrite.toLocaleString()}, total ${totalTokens.toLocaleString()}, ${elapsedSeconds.toFixed(1)}s`;
|
||||
ctx.ui.notify(message, "info");
|
||||
});
|
||||
}
|
||||
38
agent/mcp.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"tavily": {
|
||||
"url": "https://mcp.tavily.com/mcp/?tavilyApiKey=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"
|
||||
},
|
||||
"firecrawl-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "firecrawl-mcp"],
|
||||
"env": {
|
||||
"FIRECRAWL_API_KEY": "fc-61df3f09dfef45f78da631e9587641d8"
|
||||
}
|
||||
},
|
||||
"brave-search": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
"env": {
|
||||
"BRAVE_API_KEY": "BSAS-BeQ-TnIFaZzt4wrecd-9jclVNl",
|
||||
"http_proxy": "http://192.168.1.1:17892",
|
||||
"https_proxy": "http://192.168.1.1:17892",
|
||||
"HTTP_PROXY": "http://192.168.1.1:17892",
|
||||
"HTTPS_PROXY": "http://192.168.1.1:17892"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcpServersDisabled": {
|
||||
"cloudflare-search": {
|
||||
"args": [
|
||||
"-y",
|
||||
"@yrobot/cf-search-mcp"
|
||||
],
|
||||
"command": "npx",
|
||||
"env": {
|
||||
"CF_SEARCH_TOKEN": "shumengya520",
|
||||
"CF_SEARCH_URL": "https://search.smyhub.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
362
agent/models.json
Normal file
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"providers": {
|
||||
"cpa": {
|
||||
"baseUrl": "https://cpa.shumengya.top/v1",
|
||||
"apiKey": "sk-shumengya666",
|
||||
"api": "openai-completions",
|
||||
"authHeader": true,
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-5.4-mini(high)",
|
||||
"name": "gpt-5.4-mini(high)",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"grok2api": {
|
||||
"baseUrl": "https://grok2api.shumengya.top/v1",
|
||||
"apiKey": "sk-shumengya666",
|
||||
"api": "openai-completions",
|
||||
"authHeader": true,
|
||||
"models": [
|
||||
{
|
||||
"id": "grok-4.20-0309-non-reasoning",
|
||||
"name": "grok-4.20",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"deepseek": {
|
||||
"baseUrl": "https://api.deepseek.com",
|
||||
"apiKey": "sk-96928190eea94fd984e4689b5aba1548",
|
||||
"api": "openai-completions",
|
||||
"authHeader": true,
|
||||
"modelOverrides": {
|
||||
"deepseek-v4-flash": {
|
||||
"contextWindow": 500000
|
||||
}
|
||||
}
|
||||
},
|
||||
"opencode-go-openai": {
|
||||
"baseUrl": "https://opencode.ai/zen/go/v1",
|
||||
"apiKey": "sk-jPLcSAWMwDBeIIQ2w7dkonzSFbJFxvqWxEQnl8twHXX21G01QKqf6g0TVkvJuDjx",
|
||||
"api": "openai-completions",
|
||||
"authHeader": true,
|
||||
"models": [
|
||||
{
|
||||
"id": "glm-5.2",
|
||||
"name": "GLM-5.2",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"contextWindow": 1000000,
|
||||
"maxTokens": 131072,
|
||||
"cost": {
|
||||
"input": 1.4,
|
||||
"output": 4.4,
|
||||
"cacheRead": 0.26,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-5.1",
|
||||
"name": "GLM-5.1",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 16384,
|
||||
"cost": {
|
||||
"input": 1.4,
|
||||
"output": 4.4,
|
||||
"cacheRead": 0.26,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-5",
|
||||
"name": "GLM-5",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 16384,
|
||||
"cost": {
|
||||
"input": 1,
|
||||
"output": 3.2,
|
||||
"cacheRead": 0.2,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2.5",
|
||||
"name": "Kimi K2.5",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"contextWindow": 256000,
|
||||
"maxTokens": 16384,
|
||||
"cost": {
|
||||
"input": 0.6,
|
||||
"output": 3,
|
||||
"cacheRead": 0.1,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2.6",
|
||||
"name": "Kimi K2.6",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"contextWindow": 256000,
|
||||
"maxTokens": 16384,
|
||||
"cost": {
|
||||
"input": 0.95,
|
||||
"output": 4,
|
||||
"cacheRead": 0.16,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2.7-code",
|
||||
"name": "Kimi K2.7 Code",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"contextWindow": 256000,
|
||||
"maxTokens": 16384,
|
||||
"cost": {
|
||||
"input": 0.95,
|
||||
"output": 4,
|
||||
"cacheRead": 0.16,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "deepseek-v4-pro",
|
||||
"name": "DeepSeek V4 Pro",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"contextWindow": 256000,
|
||||
"maxTokens": 16384,
|
||||
"cost": {
|
||||
"input": 1.74,
|
||||
"output": 3.48,
|
||||
"cacheRead": 0.0145,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "deepseek-v4-flash",
|
||||
"name": "DeepSeek V4 Flash",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"contextWindow": 256000,
|
||||
"maxTokens": 16384,
|
||||
"cost": {
|
||||
"input": 0.14,
|
||||
"output": 0.28,
|
||||
"cacheRead": 0.0028,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mimo-v2.5",
|
||||
"name": "MiMo-V2.5",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"contextWindow": 1048576,
|
||||
"maxTokens": 131072,
|
||||
"cost": {
|
||||
"input": 0.14,
|
||||
"output": 0.28,
|
||||
"cacheRead": 0.0028,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mimo-v2.5-pro",
|
||||
"name": "MiMo-V2.5-Pro",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"contextWindow": 1048576,
|
||||
"maxTokens": 131072,
|
||||
"cost": {
|
||||
"input": 1.74,
|
||||
"output": 3.48,
|
||||
"cacheRead": 0.0145,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "minimax-m3",
|
||||
"name": "MiniMax-M3",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"contextWindow": 512000,
|
||||
"maxTokens": 131072,
|
||||
"cost": {
|
||||
"input": 0.3,
|
||||
"output": 1.2,
|
||||
"cacheRead": 0.06,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"opencode-go-anthropic": {
|
||||
"baseUrl": "https://opencode.ai/zen/go",
|
||||
"apiKey": "sk-jPLcSAWMwDBeIIQ2w7dkonzSFbJFxvqWxEQnl8twHXX21G01QKqf6g0TVkvJuDjx",
|
||||
"api": "anthropic-messages",
|
||||
"models": [
|
||||
{
|
||||
"id": "qwen3.7-max",
|
||||
"name": "Qwen3.7-Max",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"contextWindow": 1000000,
|
||||
"maxTokens": 65536,
|
||||
"cost": {
|
||||
"input": 2.5,
|
||||
"output": 7.5,
|
||||
"cacheRead": 0.5,
|
||||
"cacheWrite": 3.125
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "qwen3.7-plus",
|
||||
"name": "Qwen3.7-Plus",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"contextWindow": 1000000,
|
||||
"maxTokens": 65536,
|
||||
"cost": {
|
||||
"input": 0.4,
|
||||
"output": 1.6,
|
||||
"cacheRead": 0.04,
|
||||
"cacheWrite": 0.5
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"xiaomimimo-openai": {
|
||||
"baseUrl": "https://token-plan-cn.xiaomimimo.com/v1",
|
||||
"api": "openai-completions",
|
||||
"apiKey": "tp-cpnmi98sv9h5fqkkeueb7mei4r3gatd0ozl7nmc7jlyqviwg",
|
||||
"compat": {
|
||||
"requiresReasoningContentOnAssistantMessages": true,
|
||||
"thinkingFormat": "deepseek"
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"id": "mimo-v2.5",
|
||||
"name": "MiMo-V2.5",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"contextWindow": 1048576,
|
||||
"maxTokens": 131072,
|
||||
"cost": {
|
||||
"input": 0.4,
|
||||
"output": 2,
|
||||
"cacheRead": 0.08,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mimo-v2.5-pro",
|
||||
"name": "MiMo-V2.5-Pro",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"contextWindow": 1048576,
|
||||
"maxTokens": 131072,
|
||||
"cost": {
|
||||
"input": 1,
|
||||
"output": 3,
|
||||
"cacheRead": 0.2,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"swpumc-openai": {
|
||||
"baseUrl": "https://codeapi.swpumc.cn/v1",
|
||||
"apiKey": "sk-10325b198a8b9a701d3ace99ad25e0c0e9a02fe4c9a28d8ad31df192fbfeb1e0",
|
||||
"api": "openai-completions",
|
||||
"authHeader": true,
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-5.5",
|
||||
"name": "GPT 5.5",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 16384
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.4",
|
||||
"name": "GPT 5.4",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 16384
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.4-mini",
|
||||
"name": "GPT 5.4 Mini",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 16384
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
54
agent/prompts/cl.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
description: Audit changelog entries before release
|
||||
---
|
||||
Audit changelog entries for all commits since the last release.
|
||||
|
||||
## Process
|
||||
|
||||
1. **Find the last release tag:**
|
||||
```bash
|
||||
git tag --sort=-version:refname | head -1
|
||||
```
|
||||
|
||||
2. **List all commits since that tag:**
|
||||
```bash
|
||||
git log <tag>..HEAD --oneline
|
||||
```
|
||||
|
||||
3. **Read each package's [Unreleased] section:**
|
||||
- packages/ai/CHANGELOG.md
|
||||
- packages/tui/CHANGELOG.md
|
||||
- packages/coding-agent/CHANGELOG.md
|
||||
|
||||
4. **For each commit, check:**
|
||||
- Skip: changelog updates, doc-only changes, release housekeeping
|
||||
- Skip: changes to generated model catalogs (for example `packages/ai/src/models.generated.ts`) unless accompanied by an intentional product-facing change in non-generated source/docs.
|
||||
- Determine which package(s) the commit affects (use `git show <hash> --stat`)
|
||||
- Verify a changelog entry exists in the affected package(s)
|
||||
- For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))`
|
||||
|
||||
5. **Cross-package duplication rule:**
|
||||
Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them.
|
||||
|
||||
6. **Add New Features section after changelog fixes:**
|
||||
- Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`.
|
||||
- Propose the top new features to the user for confirmation before writing them.
|
||||
- Link to relevant docs and sections whenever possible.
|
||||
|
||||
7. **Report:**
|
||||
- List commits with missing entries
|
||||
- List entries that need cross-package duplication
|
||||
- Add any missing entries directly
|
||||
|
||||
## Changelog Format Reference
|
||||
|
||||
Sections (in order):
|
||||
- `### Breaking Changes` - API changes requiring migration
|
||||
- `### Added` - New features
|
||||
- `### Changed` - Changes to existing functionality
|
||||
- `### Fixed` - Bug fixes
|
||||
- `### Removed` - Removed features
|
||||
|
||||
Attribution:
|
||||
- Internal: `Fixed foo ([#123](https://github.com/earendil-works/pi-mono/issues/123))`
|
||||
- External: `Added bar ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@user](https://github.com/user))`
|
||||
25
agent/prompts/is.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: Analyze GitHub issues (bugs or feature requests)
|
||||
argument-hint: "<issue>"
|
||||
---
|
||||
Analyze GitHub issue(s): $ARGUMENTS
|
||||
|
||||
For each issue:
|
||||
|
||||
1. Add the `inprogress` label to the issue via GitHub CLI and assign the issue to the local `gh` user before analysis starts. If either action fails, report that explicitly and continue.
|
||||
2. Read the issue in full, including all comments and linked issues/PRs.
|
||||
3. Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path.
|
||||
|
||||
4. **For bugs**:
|
||||
- Ignore any root cause analysis in the issue (likely wrong)
|
||||
- Read all related code files in full (no truncation)
|
||||
- Trace the code path and identify the actual root cause
|
||||
- Propose a fix
|
||||
|
||||
5. **For feature requests**:
|
||||
- Do not trust implementation proposals in the issue without verification
|
||||
- Read all related code files in full (no truncation)
|
||||
- Propose the most concise implementation approach
|
||||
- List affected files and changes needed
|
||||
|
||||
Do NOT implement unless explicitly asked. Analyze and propose only.
|
||||
37
agent/prompts/pr.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: Review PRs from URLs with structured issue and code analysis
|
||||
argument-hint: "<PR-URL>"
|
||||
---
|
||||
You are given one or more GitHub PR URLs: $@
|
||||
|
||||
For each PR URL, do the following in order:
|
||||
1. Add the `inprogress` label to the PR via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue.
|
||||
2. Read the PR page in full. Include description, all comments, all commits, and all changed files.
|
||||
3. Identify any linked issues referenced in the PR body, comments, commit messages, or cross links. Read each issue in full, including all comments.
|
||||
4. Analyze the PR diff. Read all relevant code files in full with no truncation and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior.
|
||||
5. Do not check for a changelog entry. Per CONTRIBUTING.md, contributor PRs must not edit `CHANGELOG.md` — the maintainer adds the entry when merging.
|
||||
6. Check if packages/coding-agent/README.md, packages/coding-agent/docs/*.md, packages/coding-agent/examples/**/*.md require modification. This is usually the case when existing features have been changed, or new features have been added.
|
||||
7. Provide a structured review with these sections:
|
||||
- What it does: one short paragraph describing the change and its intent.
|
||||
- Good: solid choices or improvements.
|
||||
- Bad: concrete issues, regressions, missing tests, or risks.
|
||||
- Ugly: subtle or high impact problems.
|
||||
- Tests: what is covered, what is missing, and whether existing tests are adequate.
|
||||
- Open questions for you: only things blocking a merge decision that need the user's input. Omit the section entirely if there are none.
|
||||
|
||||
Output format per PR:
|
||||
PR: <url>
|
||||
What it does:
|
||||
- ...
|
||||
Good:
|
||||
- ...
|
||||
Bad:
|
||||
- ...
|
||||
Ugly:
|
||||
- ...
|
||||
Tests:
|
||||
- ...
|
||||
Open questions for you:
|
||||
- ...
|
||||
|
||||
If no issues are found, say so under Bad and Ugly.
|
||||
35
agent/prompts/wr.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
description: Finish the current task end-to-end with changelog, commit, and push
|
||||
argument-hint: "[instructions]"
|
||||
---
|
||||
Wrap it.
|
||||
|
||||
Additional instructions: $ARGUMENTS
|
||||
|
||||
Determine context from the conversation history first.
|
||||
|
||||
Rules for context detection:
|
||||
- If the conversation already mentions a GitHub issue or PR, use that existing context.
|
||||
- If the work came from `/is` or `/pr`, assume the issue or PR context is already known from the conversation and from the analysis work already done.
|
||||
- If there is no GitHub issue or PR in the conversation history, treat this as non-GitHub work.
|
||||
|
||||
Unless I explicitly override something in this request, do the following in order:
|
||||
|
||||
1. Add or update the relevant package changelog entry under `## [Unreleased]` using the repo changelog rules.
|
||||
2. If this task is tied to a GitHub issue or PR and a final issue or PR comment has not already been posted in this session, draft it in my tone, preview it, and post exactly one final comment. The comment must end with this exact standalone disclaimer line, with no variations:
|
||||
|
||||
```text
|
||||
This comment is AI-generated by `/wr`
|
||||
```
|
||||
3. Commit only files you changed in this session.
|
||||
4. If this task is tied to exactly one GitHub issue, include `closes #<issue>` in the commit message. If it is tied to multiple issues, stop and ask which one to use. If it is not tied to any issue, do not include `closes #` or `fixes #` in the commit message.
|
||||
5. Check the current git branch. If it is not `main`, stop and ask what to do. Do not push from another branch unless I explicitly say so.
|
||||
6. Push the current branch.
|
||||
|
||||
Constraints:
|
||||
- Never stage unrelated files.
|
||||
- Never use `git add .` or `git add -A`.
|
||||
- Run required checks before committing if code changed.
|
||||
- Do not open a PR unless I explicitly ask.
|
||||
- If this is not GitHub issue or PR work, do not post a GitHub comment.
|
||||
- If a final issue or PR comment was already posted in this session, do not post another one unless I explicitly ask.
|
||||
4
agent/prompts/代码注释改中文.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
description: 将项目代码中的英文或者其他语言注释改成中文
|
||||
---
|
||||
代码注释给我默认使用中文注释,不要使用英文注释,如果原本有英文注释,请给我改成中文注释
|
||||
1
agent/prompts/构建项目启动脚本.md
Normal file
@@ -0,0 +1 @@
|
||||
给我在项目根目录写一键启动本地开发前后端bat脚本和bash脚本,脚本名叫"dev",和构建前端脚本,脚本名叫:"build",注意保持代码精简不啰嗦,注意在Windows上显示前后端要两个窗口显示
|
||||
1
agent/prompts/项目起名.md
Normal file
@@ -0,0 +1 @@
|
||||
给我这个项目起个简单通俗易懂的英文名
|
||||
14
agent/settings.example.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"lastChangelogVersion": "0.75.4",
|
||||
"showChangelogOnStartup": false,
|
||||
"defaultProvider": "your-provider",
|
||||
"defaultModel": "your-model",
|
||||
"defaultThinkingLevel": "high",
|
||||
"transport": "sse",
|
||||
"theme": "dark",
|
||||
"packages": [
|
||||
"npm:pi-subagents",
|
||||
"npm:pi-mcp-adapter",
|
||||
"npm:pi-autocontext"
|
||||
]
|
||||
}
|
||||
23
agent/settings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"lastChangelogVersion": "0.75.4",
|
||||
"showChangelogOnStartup": false,
|
||||
"defaultProvider": "opencode-go",
|
||||
"defaultModel": "glm-5.2",
|
||||
"defaultThinkingLevel": "high",
|
||||
"transport": "sse",
|
||||
"theme": "dark",
|
||||
"skills": [
|
||||
"-skills/repo-to-resume-tailor/SKILL.md",
|
||||
"-skills/mcp-builder/SKILL.md",
|
||||
"+skills/command-help-zh-skill/SKILL.md",
|
||||
"+skills/officecli/SKILL.md",
|
||||
"+skills/gitea-tea-skill/SKILL.md",
|
||||
"+skills/github-gh-skill/SKILL.md",
|
||||
"+skills/linux-ssh-operator-skill/SKILL.md"
|
||||
],
|
||||
"extensions": [
|
||||
"-extensions/redraws/index.ts",
|
||||
"-extensions/status-line/index.ts",
|
||||
"-extensions/prompt-url-widget/index.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "software-copyright-materials",
|
||||
"description": "从真实项目生成中国软件著作权申请资料(Word/TXT)。支持项目分析、代码材料抽取、申请表信息生成和操作手册撰写,输出符合软著申报要求的正式材料。",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Fokkyp",
|
||||
"url": "https://github.com/Fokkyp"
|
||||
},
|
||||
"homepage": "https://github.com/Fokkyp/SoftwareCopyright-Skill",
|
||||
"repository": "https://github.com/Fokkyp/SoftwareCopyright-Skill",
|
||||
"license": "MIT",
|
||||
"keywords": ["software-copyright", "chinese-copyright", "docx", "软著", "著作权"]
|
||||
}
|
||||
54
agent/skills-disabled/SoftwareCopyright-Skill/.gitignore
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.Python
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# .NET build outputs
|
||||
bin/
|
||||
obj/
|
||||
**/bin/
|
||||
**/obj/
|
||||
|
||||
# Local generated materials
|
||||
软件著作权申请资料/
|
||||
**/软件著作权申请资料/
|
||||
|
||||
# Temporary and preview outputs
|
||||
*.tmp
|
||||
*.temp
|
||||
*.log
|
||||
*.pdf
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.webp
|
||||
!docs/
|
||||
!docs/screenshots/
|
||||
!docs/screenshots/*.png
|
||||
|
||||
# Office lock files
|
||||
~$*.docx
|
||||
~$*.xlsx
|
||||
~$*.pptx
|
||||
|
||||
# Editor / IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Keep the generated demo in this repository
|
||||
!生成demo/
|
||||
!生成demo/**
|
||||
生成demo/**/.DS_Store
|
||||
21
agent/skills-disabled/SoftwareCopyright-Skill/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Software Copyright Materials contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
241
agent/skills-disabled/SoftwareCopyright-Skill/README.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Software Copyright Materials Skill
|
||||
|
||||
这是一个用于生成中文软件著作权申请资料的 Skill / 插件仓库,可用于 Codex,也可作为 Claude Code 插件加载。
|
||||
|
||||
项目地址:https://github.com/Fokkyp/SoftwareCopyright-Skill
|
||||
|
||||
真正的 Skill 位于:
|
||||
|
||||
```text
|
||||
software-copyright-materials/
|
||||
```
|
||||
|
||||
安装时不要把仓库根目录当作普通单个 skill 复制。Codex 使用 `software-copyright-materials/` 这个实际 skill 目录;Claude Code 使用仓库根目录中的 `.claude-plugin/plugin.json` 和 `skills/` 插件结构。
|
||||
|
||||
## 功能概览
|
||||
|
||||
> **本项目完全免费。请不要相信任何使用本项目包装出来的付费服务。**
|
||||
|
||||
软件著作权申请本身不神秘,真正麻烦的是整理材料:申请表字段要写对,操作手册要像样,代码材料要按规则截取,软件名称、版本号、页数还要保持一致。很多开发者最后会把这件事交给付费代办或资料整理服务,花钱买的往往也只是这些文档整理工作。
|
||||
|
||||
这个 skill 的目标很直接:让开发者不用再为整理软著材料额外付费,也不用把项目代码和产品细节交给外部商家来回沟通。把真实项目交给支持该 skill 的代码助手,它会按流程引导你确认关键信息,并在本地生成一整套可检查、可修改、可提交前再导出的软著申请资料。
|
||||
|
||||
- **自己生成整套资料**:从项目分析、业务理解、申请表信息、操作手册到代码材料,一套流程跑完,不再依赖外部代办整理文档。
|
||||
- **从真实源码抽取代码**:代码材料只来自开发者已有项目,禁止 AI 编造源码,适合对材料真实性敏感的开发者。
|
||||
- **自动处理前 30 页 / 后 30 页规则**:源码足够时按常见鉴别材料要求生成前 30 页和后 30 页;不足 60 页时按规则生成全部代码材料。
|
||||
- **操作手册不套模板**:先理解项目业务、页面和功能,再写面向审核员的操作说明,避免只有空泛功能列表。
|
||||
- **申请表字段集中整理**:软件名称、版本号、著作权人、开发环境、运行环境、源程序量、功能说明等字段统一生成到 `申请表信息.txt`,官网填报时可以对照复制。
|
||||
- **关键节点都让你确认**:业务口径、申请表字段、代码选择、截图方式、最终 Markdown 草稿都会停下来让开发者确认,减少材料写偏的风险。
|
||||
- **Word/TXT 一键输出**:确认后生成操作手册 DOCX、代码材料 DOCX 和申请表 TXT,文件统一放在 `软件著作权申请资料/正式资料/`。
|
||||
- **本地生成,资料可控**:默认在当前项目目录生成材料,代码、文档和草稿都留在本地,方便开发者自行审阅、修改和归档。
|
||||
- **提供完整 demo**:仓库内提供 [`生成demo/软件著作权申请资料/`](生成demo/软件著作权申请资料/),可以直接点击查看生成后的草稿、正式资料和填报辅助文件。
|
||||
|
||||
## 演示截图
|
||||
|
||||
| 生成流程 | 生成流程 |
|
||||
|---------|---------|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── docs/
|
||||
│ └── screenshots/
|
||||
│ ├── demo-1.png
|
||||
│ ├── demo-2.png
|
||||
│ ├── demo-3.png
|
||||
│ ├── demo-4.png
|
||||
│ ├── demo-5.png
|
||||
│ ├── demo-6.png
|
||||
│ └── 著作权申请表.png
|
||||
├── software-copyright-materials/
|
||||
│ ├── SKILL.md
|
||||
│ ├── agents/
|
||||
│ ├── references/
|
||||
│ ├── scripts/
|
||||
│ └── vendor/
|
||||
└── 生成demo/
|
||||
└── 软件著作权申请资料/
|
||||
├── 草稿/
|
||||
└── 正式资料/
|
||||
├── 申请表信息.txt
|
||||
├── 软件名称_操作手册.docx
|
||||
├── 软件名称-代码(前30页).docx
|
||||
└── 软件名称-代码(后30页).docx
|
||||
```
|
||||
|
||||
## 下载并安装
|
||||
|
||||
先获取本仓库。会用 Git 的用户执行:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Fokkyp/SoftwareCopyright-Skill.git
|
||||
cd SoftwareCopyright-Skill
|
||||
```
|
||||
|
||||
不会用 Git 的用户可以在 GitHub 页面点击 `Code` -> `Download ZIP`,解压后进入仓库目录。目录中应能看到:
|
||||
|
||||
```text
|
||||
software-copyright-materials/
|
||||
.claude-plugin/plugin.json
|
||||
skills/software-copyright-materials
|
||||
```
|
||||
|
||||
按使用工具选择一种方式:
|
||||
|
||||
**Codex**:复制实际 skill 目录。
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.codex/skills
|
||||
cp -R software-copyright-materials ~/.codex/skills/
|
||||
```
|
||||
|
||||
**Claude Code**:把本仓库作为插件目录加载。
|
||||
|
||||
```bash
|
||||
claude --plugin-dir /path/to/SoftwareCopyright-Skill
|
||||
```
|
||||
|
||||
在本仓库目录中可以直接执行:
|
||||
|
||||
```bash
|
||||
claude --plugin-dir .
|
||||
```
|
||||
|
||||
Claude Code 手动调用时使用插件命名空间:
|
||||
|
||||
```text
|
||||
/software-copyright-materials:software-copyright-materials
|
||||
```
|
||||
|
||||
如果只想在某个项目里使用,Codex 可复制到该项目的 `.codex/skills/`:
|
||||
|
||||
```bash
|
||||
PROJECT_DIR="<你的项目目录>"
|
||||
mkdir -p "$PROJECT_DIR/.codex/skills"
|
||||
cp -R software-copyright-materials "$PROJECT_DIR/.codex/skills/"
|
||||
```
|
||||
|
||||
Claude Code 可在目标项目目录启动时指定本仓库:
|
||||
|
||||
```bash
|
||||
cd "<你的项目目录>"
|
||||
claude --plugin-dir "<SoftwareCopyright-Skill 仓库路径>"
|
||||
```
|
||||
|
||||
修改 README 或 skill 后,重启会话,或在 Claude Code 中执行 `/reload-plugins` 重新加载。
|
||||
|
||||
## 运行要求和环境校验
|
||||
|
||||
### 必需环境
|
||||
|
||||
- **支持 Skill / Plugin 的代码助手**:Codex 可按 skill 目录加载;Claude Code 可按插件目录加载。
|
||||
- **Python 3.10+ 和 python-docx**:生成流程依赖 `software-copyright-materials/scripts/` 下的 Python 脚本,用于分析项目、生成草稿、抽取真实代码、校验字段和生成正式资料。
|
||||
- **可读取的项目源码**:代码材料必须从真实项目中抽取,所以需要在代码助手中打开或指定你的项目目录。
|
||||
|
||||
安装 Python 依赖:
|
||||
|
||||
```bash
|
||||
python3 -m pip install python-docx
|
||||
```
|
||||
|
||||
### 可选环境
|
||||
|
||||
- **.NET SDK 8.0+**:用于启用更完整的 DOCX OpenXML 生成和校验能力。没有 .NET SDK 也可以继续使用基础 DOCX 兜底生成。
|
||||
- **Chrome DevTools MCP**:只有在你希望自动截取网页截图时才需要。
|
||||
- **Codex Computer Use**:仅 Codex 环境需要桌面界面操作和截图时使用。
|
||||
- **用户自行截图**:如果没有 MCP 或 Computer Use,也可以手动把截图放到指定目录,或者直接跳过截图。
|
||||
|
||||
### 使用过程中会自动检查吗?
|
||||
|
||||
会。每次开始生成资料时,skill 会先运行环境检查,并在当前目录生成:
|
||||
|
||||
```text
|
||||
软件著作权申请资料/环境检查.md
|
||||
软件著作权申请资料/环境检查.json
|
||||
```
|
||||
|
||||
环境检查会告诉你:
|
||||
|
||||
- Markdown 草稿、TXT、基础 DOCX 是否可用。
|
||||
- 完整 DOCX OpenXML 环境是否可用。
|
||||
- `.NET SDK` 是否缺失。
|
||||
- 当前会把材料生成到哪里。
|
||||
|
||||
如果完整 DOCX 环境缺失,代码助手会停下来让你选择:
|
||||
|
||||
1. 安装完整 DOCX 环境。
|
||||
2. 使用基础 DOCX 兜底继续。
|
||||
|
||||
它不会在你不确认的情况下静默安装依赖。截图也一样,会先让你选择 Chrome DevTools MCP、Codex Computer Use、用户自行截图或跳过截图;如果你跳过截图,操作手册里会保留可见的截图预留位置。
|
||||
|
||||
## 基本使用
|
||||
|
||||
安装或加载完成后,在代码助手中打开需要生成软著资料的项目,然后直接说:
|
||||
|
||||
```text
|
||||
使用 software-copyright-materials 生成当前项目的软件著作权申请资料
|
||||
```
|
||||
|
||||
在 Claude Code 中也可以手动调用:
|
||||
|
||||
```text
|
||||
/software-copyright-materials:software-copyright-materials 读取当前目录中的项目,生成软件著作权申请资料草稿
|
||||
```
|
||||
|
||||
代码助手会按流程引导填写信息、确认草稿,并在当前项目目录下生成 `软件著作权申请资料/`。
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目采用 [MIT License](LICENSE) 开源。你可以自由使用、复制、修改、分发,也可以基于它继续开发自己的版本。使用者仍需自行核对生成材料是否符合实际项目和官网当前要求。
|
||||
|
||||
## 代码材料说明
|
||||
|
||||
依据软件著作权申请材料要求,代码鉴别材料应来自申请软件本身。本 skill 不通过 AI 生成项目代码,也不编造不存在的源码内容。
|
||||
|
||||
本 skill 的作用是帮助开发者从已有项目中理解业务、选择代码文件、提取前后代码材料,并整理为便于编辑和提交的文档格式。开发者应在提交前自行核对代码材料是否来自真实项目、软件名称和版本号是否与申请表保持一致。
|
||||
|
||||
## 官网填报和提交
|
||||
|
||||
官方入口:
|
||||
|
||||
- 中国版权保护中心:https://www.ccopyright.com.cn/
|
||||
- 著作权登记系统:https://register.ccopyright.com.cn/login.html
|
||||
- 法规依据:《计算机软件著作权登记办法》:https://www.gov.cn/zhengce/2002-02/20/content_5724627.htm
|
||||
|
||||
官方页面可能会调整,实际填报时以官网当前页面为准。
|
||||
|
||||
### 著作权申请表填写示例
|
||||
|
||||
著作权申请表按照以下图片填写。
|
||||
|
||||

|
||||
|
||||
### 申请流程
|
||||
|
||||
1. 打开中国版权保护中心官网,进入著作权登记系统。
|
||||
2. 注册或登录账号,并按页面提示完成实名认证。
|
||||
3. 进入软件著作权相关业务,选择计算机软件著作权登记申请。
|
||||
4. 在线填写申请表。可以打开本工具生成的 `正式资料/申请表信息.txt`,把软件名称、版本号、开发完成日期、开发环境、运行环境、功能说明等内容复制到官网对应字段。
|
||||
5. 上传申请材料。根据官网要求上传 PDF 格式文件和其他证明材料。
|
||||
6. 核对信息无误后提交申请,并按官网提示查看受理、补正或登记结果。
|
||||
|
||||
### 生成文件怎么用
|
||||
|
||||
`申请表信息.txt` 是填报辅助文件,用来帮助开发者在官网填写申请表,不是直接上传的申请材料。
|
||||
|
||||
`docx` 文件是本地编辑稿,方便开发者在 Word、WPS 或 Pages 中继续修改。提交官网前,请将需要上传的 `docx` 文件导出或另存为 PDF,再按官网要求上传。
|
||||
|
||||
实际文件名会包含软件名称。通常需要转换为 PDF 的文件包括:
|
||||
|
||||
- `操作手册.docx`
|
||||
- `代码(前30页).docx`
|
||||
- `代码(后30页).docx`
|
||||
- 不足 60 页时生成的全部代码材料
|
||||
|
||||
申请人身份证明、权属证明、委托材料等其他文件,请按官网页面要求另行准备并上传。
|
||||
|
||||
<p align="left"><sub>友情连接:<a href="https://linux.do/">Linux Do 社区</a> · <a href="https://www.v2ex.com/">V2EX</a></sub></p>
|
||||
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 505 KiB |
@@ -0,0 +1 @@
|
||||
/shumengya/project/agent/sproutclaw/.pi/agent/skills/SoftwareCopyright-Skill/software-copyright-materials
|
||||
@@ -0,0 +1,502 @@
|
||||
---
|
||||
name: software-copyright-materials
|
||||
description: >
|
||||
Generate guided Chinese software copyright application materials from a real project.
|
||||
Use this skill when the user asks for 软件著作权, 软著申请资料, 软著代码材料,
|
||||
操作手册, 申请表信息, or wants Word/TXT materials for software copyright registration.
|
||||
The workflow analyzes the imported project, extracts real source code, creates Markdown
|
||||
drafts for user confirmation, then uses bundled DOCX tooling to produce final
|
||||
Word documents and TXT.
|
||||
user-invocable: true
|
||||
compatibility: >
|
||||
Requires Python 3.10+ with python-docx (pip install python-docx).
|
||||
Optional: .NET SDK 8.0+ for full OpenXML DOCX validation (run vendor/docx-toolkit/scripts/setup.sh).
|
||||
allowed-tools: >
|
||||
Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch
|
||||
metadata:
|
||||
short-description: 生成软著申请资料 Word/TXT
|
||||
author: Fokkyp
|
||||
version: "1.1"
|
||||
repository: https://github.com/Fokkyp/SoftwareCopyright-Skill
|
||||
---
|
||||
|
||||
# 软著申请资料生成
|
||||
|
||||
这个 skill 生成可审阅、可追溯的软著申请资料。核心原则:
|
||||
|
||||
- 固定输出目录:当前工作目录下的 `软件著作权申请资料/`。不要默认写到 `/tmp`、`/private/tmp` 或其他临时目录。
|
||||
- 只有测试 skill 自身时才允许显式指定临时目录;面向用户生成材料时必须写入当前目录。
|
||||
- 先生成 Markdown 草稿,用户确认后再生成正式 Word/TXT。
|
||||
- 正式 Word/TXT 只能写入 `软件著作权申请资料/正式资料/`,不要散落在输出目录根部。
|
||||
- 正式 Word/TXT 的文字一律使用默认黑色字体,不生成蓝色超链接、主题色标题或其他彩色文字;Markdown 链接写入 Word 时必须转成普通文本。
|
||||
- 正式资料中的软件名称必须与 `草稿/申请表信息.md` 的“软件全称”字段一致;正式生成时以已确认的申请表软件全称为准。
|
||||
- 正式代码 Word 页眉中的版本号必须与 `草稿/申请表信息.md` 的“版本号”字段一致;正式生成时以已确认的申请表版本号为准。
|
||||
- 代码材料必须来自真实项目源码,禁止 AI 编造代码。
|
||||
- 写申请表和操作手册前,必须先形成模型研判后的 `草稿/业务理解.md/json`,理解软件业务、行业、目标用户、核心价值和操作流程。
|
||||
- 脚本只能收集项目证据、校验字段和生成文件;行业判断、功能抽取、代码抽取选择、操作手册结构必须由模型阅读项目后决定,不得依赖脚本关键字表或固定范本。
|
||||
- 优先抽取前端代码:入口、路由、页面、核心组件、接口封装、状态管理、工具函数。
|
||||
- 生成代码材料前,必须先生成代码文件候选清单;模型理解项目后填写抽取文件和选择理由,再让用户确认或修改。
|
||||
- 代码优先抽取模型和用户确认的、最能体现软件真实功能和运行逻辑的源码;不足 60 页时,从其他相关源码文件补充到 60 页;候选源码仍不足 60 页时,才生成全部代码文档。
|
||||
- 操作手册成稿应像真实软件随附的操作说明,而不是研发说明、功能清单或 AI 生成的汇总文。
|
||||
- 操作手册草稿必须按传统软著操作手册骨架组织:相关文档、说明、功能特点、系统要求、按真实页面/流程逐章操作、常见问题解答、术语表。一级章节标题使用中文大写序号,例如 `一、相关文档`,不得使用 `(1)、相关文档`。相关文档必须用表格指向总体设计、详细设计、测试案例等配套文档。正文尽量使用连续段落,不使用项目符号列表或 `1. 2. 3.` 编号列表。
|
||||
- 每个核心页面都要用普通用户视角说明页面用途、进入位置、用户可见内容、用户动作、输入限制或异常提示、结果反馈和截图预留。不得把章节写成“进入方式:/页面内容:/操作步骤:/操作规则:/操作结果与反馈:”这种字段模板;这些信息要自然合并到段落里。避免代码、框架、接口、状态管理、异步任务等技术化表达;撰写过程中由 agent 自行循环检查、扩写和修正,完整草稿完成后只向用户发起一次整体确认。
|
||||
- 操作手册必须去除明显“AI 味”:避免空泛赞美、营销口号、万能句式、每章同一结构、头中尾固定结构、过度对称的排比、没有项目细节的正确废话、频繁使用“旨在、赋能、一站式、智能化、高效便捷、显著提升、强大能力、丰富功能”等套话。每段都应能回答“这个项目里这个功能具体做什么、用户看见什么、操作后有什么结果”。
|
||||
- 操作手册生成必须同步输出 `草稿/操作手册自检记录.md` 和 `草稿/操作手册自检记录.json`,记录初稿、按项目流程扩写、去制式表达等自检轮次;如果前 3 轮仍发现问题,必须继续补写修正,直到问题清零或记录无法自动修复的原因后再停止。
|
||||
- 截图方式必须先让用户选择:Chrome DevTools MCP、Codex Computer Use、用户自行截图。用户选完后,再检查当前 MCP / Computer Use 能力是否可用;如果用户说现在不截图、先跳过截图或截图失败,操作手册仍必须保留清晰可见的截图预留位置,正式 Word 中也要能看到。
|
||||
- 申请表信息中的硬件/系统环境必须让用户确认或填写,不能硬编码。
|
||||
- Word 生成能力必须使用本 skill 内置的 `vendor/docx-toolkit`;不得引用外部 DOCX 目录。
|
||||
|
||||
## 强制人工门禁
|
||||
|
||||
凡是涉及用户选择、确认或补充信息的阶段,必须先停止当前执行,不得继续调用下一步脚本。即使处于自动审核、自动继续或无人值守模式,也必须把 `STOP_FOR_USER` 和 `NEXT_ACTION` 原样告知用户,并等待用户输入后再继续。
|
||||
|
||||
禁止使用“用户未选择则默认继续”的逻辑。用户回复确认后,先用确认脚本记录对应门禁,再进入下一阶段:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/confirm_stage.py --workdir 软件著作权申请资料 --stage <阶段名> --note "<用户确认内容>"
|
||||
```
|
||||
|
||||
必须停住的门禁:
|
||||
|
||||
- `environment`:完整 DOCX 环境缺失时,用户必须选择“安装完整环境”或“使用基础 DOCX 兜底继续”。
|
||||
- `project`:存在多个项目候选目录时,用户必须指定项目目录。
|
||||
- `business`:`草稿/业务理解.md` 生成后,用户必须确认行业、目标用户、核心功能和申请口径。
|
||||
- `application-fields`:`草稿/申请表信息.md` 生成后,用户必须补全并确认硬件、系统环境、著作权人、日期等字段。
|
||||
- `code-selection`:`草稿/代码文件选择.json` 生成后,用户必须确认或修改抽取文件。
|
||||
- `screenshot-method`:操作手册截图前,用户必须在 Chrome DevTools MCP、Codex Computer Use、用户自行截图三种方式中选择一种;如果用户明确说“现在不截图/先跳过截图”,记录为 `skip`。
|
||||
- `markdown`:全部 Markdown 草稿完成后,用户必须确认可以进入 Word/TXT 生成。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 1. 启动环境检查
|
||||
|
||||
一开始先在当前工作目录创建输出目录并检查运行能力:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/check_environment.py \
|
||||
--out-dir 软件著作权申请资料
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
- `软件著作权申请资料/环境检查.md`
|
||||
- `软件著作权申请资料/环境检查.json`
|
||||
|
||||
环境检查必须告诉用户:
|
||||
|
||||
- 当前会在“当前目录/软件著作权申请资料”下生成材料。
|
||||
- Markdown 草稿、TXT、基础 DOCX 是否可用。
|
||||
- 内置 `vendor/docx-toolkit` 的完整 OpenXML 环境是否可用。
|
||||
- 如 `.NET SDK` 缺失,询问用户是否安装完整环境。
|
||||
|
||||
用户选择:
|
||||
|
||||
- 如果用户愿意安装完整环境,按 `${CLAUDE_SKILL_DIR}/vendor/docx-toolkit/scripts/setup.sh` 的要求安装依赖,再继续。完整环境生成和校验更规范。
|
||||
- 如果用户不安装,继续使用兜底方案生成 Markdown、TXT 和基础 DOCX。
|
||||
- 如果完整 DOCX 环境缺失,必须停止并等待用户选择;不得自动继续。
|
||||
|
||||
用户回复后记录门禁:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/confirm_stage.py \
|
||||
--workdir 软件著作权申请资料 \
|
||||
--stage environment \
|
||||
--note "<用户选择>"
|
||||
```
|
||||
|
||||
不要等到最后验证阶段才发现完整 DOCX 环境不可用;这个信息必须在流程开始时给出。
|
||||
|
||||
### 2. 定位项目
|
||||
|
||||
用户通常会把项目放在当前文件夹下。先扫描当前目录,避开本 skill、自身输出目录、`node_modules`、构建产物和隐藏目录,找到最可能的项目根目录。
|
||||
|
||||
如果有多个候选项目,必须停止并询问用户选择;如果只有一个明显候选项目,可以直接使用。
|
||||
|
||||
### 3. 分析项目
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/analyze_project.py \
|
||||
--project <项目目录> \
|
||||
--out 软件著作权申请资料/analysis/project.json
|
||||
```
|
||||
|
||||
分析内容包括:
|
||||
|
||||
- `package.json`、README、脚本命令、依赖
|
||||
- 前端框架和主要编程语言
|
||||
- 入口文件、路由、页面、组件、接口、状态管理
|
||||
- 源码文件数量和源程序行数
|
||||
- 软件名称候选、主要功能候选、运行命令候选
|
||||
|
||||
### 4. 形成业务理解
|
||||
|
||||
在写申请表和操作手册前,先让脚本收集项目证据:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/generate_business_context.py \
|
||||
--project <项目目录> \
|
||||
--analysis 软件著作权申请资料/analysis/project.json \
|
||||
--software-name "<软件全称>" \
|
||||
--out-dir 软件著作权申请资料/草稿
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
- `草稿/业务理解证据.md`
|
||||
- `草稿/业务理解证据.json`
|
||||
- `草稿/业务理解模型稿模板.json`
|
||||
|
||||
这一步只收集证据,不决定最终业务口径。接下来必须由模型阅读 `业务理解证据.md/json`、README、PRD/BRD、页面文案、路由、接口、必要源码和用户补充资料,自行判断:
|
||||
|
||||
- 应该重点读取哪些文档和源码
|
||||
- 软件属于什么行业 / 领域
|
||||
- 目标用户是谁
|
||||
- 核心价值是什么
|
||||
- 哪些功能应写入软著申请资料
|
||||
- 典型操作流程如何组织
|
||||
- 操作手册适合采用什么章节结构
|
||||
- 申请表建议口径如何表达
|
||||
|
||||
模型不得用脚本关键字表决定行业、功能和结构;不得把用户给的范本文案、测试项目名称、测试项目流程写成通用规则。
|
||||
|
||||
模型完成研判后,生成一个业务理解模型稿 JSON,字段至少包含:
|
||||
|
||||
- `product_positioning`
|
||||
- `industry`
|
||||
- `target_users`
|
||||
- `core_value`
|
||||
- `business_features`
|
||||
- `business_feature_details`
|
||||
- `operation_flow`
|
||||
- `application_purpose`
|
||||
- `main_functions`
|
||||
- `technical_characteristics`
|
||||
- `manual_sections`
|
||||
- `manual_modules`
|
||||
- `system_requirements`
|
||||
- `faq`
|
||||
- `glossary`
|
||||
|
||||
其中 `manual_modules` 是操作手册的核心输入,必须按真实页面、导航入口或业务流程填写。脚本不得按 `auth/query/form` 等分类模板自动补入口、步骤或反馈;缺少 `manual_modules` 或关键字段时必须停止让模型回到项目证据中补写。每个模块必须包含:
|
||||
|
||||
- `title`:页面或流程名称。
|
||||
- `evidence`:对应页面、路由、组件、需求文档或 README 证据。
|
||||
- `purpose`:该页面在软件中的用途。
|
||||
- `usage` 或 `usage_scenario`:用户在什么业务场景下会使用该页面,正在处理什么具体事务。缺少时不得生成操作手册。
|
||||
- `entry`:用户从哪里进入该页面。
|
||||
- `visible_elements`:用户实际能看到的输入框、按钮、列表、标签、状态或结果区域。
|
||||
- `operation_steps`:按真实页面顺序写用户动作,不能写代码实现。
|
||||
- `validation_rules`:必填项、长度限制、权限、额度、状态、异常提示等规则;没有则留空数组。
|
||||
- `feedback`:操作完成后用户能看到的结果、提示或状态变化。
|
||||
- `screenshot`:截图预留说明。
|
||||
|
||||
`manual_sections` 只允许补充当前软件本身的用途、业务场景、页面组织或用户流程,不要写“本操作手册用于……”“面向软著审核……”“不描述代码实现……”这类解释文档写作方式的元话语。最终操作手册应像真实软件说明书,而不是生成过程说明。
|
||||
|
||||
然后运行:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/generate_business_context.py \
|
||||
--project <项目目录> \
|
||||
--analysis 软件著作权申请资料/analysis/project.json \
|
||||
--software-name "<软件全称>" \
|
||||
--out-dir 软件著作权申请资料/草稿 \
|
||||
--model-context <模型生成的业务理解JSON>
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
- `草稿/业务理解.md`
|
||||
- `草稿/业务理解.json`
|
||||
|
||||
最终业务理解必须覆盖:
|
||||
|
||||
- 产品定位
|
||||
- 面向领域 / 行业
|
||||
- 目标用户
|
||||
- 核心价值
|
||||
- 主要业务功能
|
||||
- 典型操作流程
|
||||
- 申请表建议口径
|
||||
- 证据来源
|
||||
- 操作手册结构建议
|
||||
|
||||
如果项目材料不足、业务类型较新,或用户明确希望参考竞品,可联网搜索相近产品和行业资料;外部调研只用于理解行业表达,不能编造项目不存在的功能。调研摘要应写入业务理解草稿,并区分“项目证据”和“行业参考”。
|
||||
|
||||
生成 `业务理解.md/json` 后必须停止,等待用户确认或修改。业务理解确认前,不得生成申请表和操作手册。如果业务理解仍不充分,先请用户补充产品说明。用户确认后运行:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/confirm_stage.py \
|
||||
--workdir 软件著作权申请资料 \
|
||||
--stage business \
|
||||
--note "<用户确认内容>"
|
||||
```
|
||||
|
||||
### 5. 引导用户确认字段
|
||||
|
||||
根据分析结果,向用户确认以下字段(按官网实际表单顺序):
|
||||
|
||||
- 软件全称
|
||||
- 软件简称(可选)
|
||||
- 版本号
|
||||
- 软件分类(应用软件/嵌入式软件/中间件/系统软件/其他)
|
||||
- 开发完成日期(YYYY-MM-DD 格式)
|
||||
- 开发方式(单独开发/合作开发/委托开发/下达任务开发)
|
||||
- 软件说明(原创 / 修改(含翻译软件、合成软件))
|
||||
- 发表状态(已发表/未发表)
|
||||
- 首次发表日期(已发表时填写,YYYY-MM-DD 格式;未发表则留空)
|
||||
- 著作权人(复合字段:国家/省市/类型[自然人/法人]/姓名/证件类型/证件号)
|
||||
- 权利范围(全部权利/部分权利)
|
||||
- 权利取得方式(原始取得/继受取得)
|
||||
- 开发的硬件环境(≤50字符)
|
||||
- 运行的硬件环境(≤50字符)
|
||||
- 开发该软件的操作系统(≤50字符)
|
||||
- 软件开发环境 / 开发工具(≤50字符,格式:开发环境: xxx/开发工具: xxx)
|
||||
- 该软件的运行平台 / 操作系统(≤50字符)
|
||||
- 软件运行支撑环境 / 支持软件(≤50字符)
|
||||
- 编程语言(预设按钮选择 + 自定义输入≤120字符)
|
||||
- 源程序量(纯数字,单位为行,指全部源程序总行数)
|
||||
- 开发目的(≤50字符)
|
||||
- 面向领域 / 行业(≤50字符)
|
||||
- 软件的主要功能(500~1300字符)
|
||||
- 软件的技术特点(多选标签 + 文本描述≤100字符;标签:APP/游戏软件/教育软件/金融软件/医疗软件/地理信息软件/云计算软件/信息安全软件/大数据软件/人工智能软件/VR软件/5G软件/小程序/物联网软件/智慧城市软件,都不符合时可不选)
|
||||
- 页数(代码鉴别材料实际页数)
|
||||
|
||||
项目可推断字段可以先给建议值;硬件/系统环境必须允许用户选择建议值或手动填写。字段口径必须区分清楚:
|
||||
|
||||
- 软件全称:必须由用户确认。最终正式资料文件名、代码 Word 页眉、操作手册标题和正文中的软件名称,都必须与 `申请表信息.md` 的"软件全称"字段一致。
|
||||
- 软件简称:可选字段,如有常用简称则填写。
|
||||
- 版本号:必须由用户确认。优先读取项目配置中的版本号作为证据;如果项目版本号小于 V1.0(例如 V0.1.0、V0.9.0),必须明确询问用户"软著首次提交通常写 V1.0,本次填写 V1.0 还是项目当前版本号"。最终 `申请表信息.md` 的"版本号"字段就是正式资料版本号。
|
||||
- 软件分类:应用软件/嵌入式软件/中间件/系统软件/其他,默认为应用软件。
|
||||
- 开发完成日期、首次发表日期:必须使用 YYYY-MM-DD 格式。
|
||||
- 开发方式:单独开发/合作开发/委托开发/下达任务开发,默认单独开发。
|
||||
- 软件说明:原创 / 修改(含翻译软件、合成软件),默认原创。
|
||||
- 发表状态:已发表或未发表;已发表需附首次发表日期,未发表则首次发表日期留空。
|
||||
- 软件开发环境 / 开发工具:≤50字符,格式为 `开发环境: <操作系统>/开发工具: <IDE名称>`,例如 `开发环境: Windows 11/开发工具: Visual Studio Code`;不要把 React、Next.js、Vite、TypeScript 等技术栈写到此字段。
|
||||
- 开发该软件的操作系统:≤50字符,填写实际开发电脑的操作系统版本,例如 Windows 10、Windows 11、macOS 14、macOS 15。
|
||||
- 该软件的运行平台 / 操作系统:≤50字符,填写软件运行所在的操作系统或浏览器环境。
|
||||
- 软件运行支撑环境 / 支持软件:≤50字符,直接列出运行依赖(如 Node.js、npm、浏览器),不加格式前缀。
|
||||
- 开发的硬件环境:≤50字符,优先读取当前电脑 CPU、内存、硬盘配置作为建议值;读取不到时让用户填写。
|
||||
- 运行的硬件环境:≤50字符,默认可沿用开发硬件环境建议值,也可以按实际部署或运行设备修改。
|
||||
- 源程序量:纯数字(不含"行"字),指登记软件全部源程序的总行数。
|
||||
- 开发目的:≤50字符,用一句话说明软件开发目的,不能只写软件名称。
|
||||
- 面向领域 / 行业:≤50字符。
|
||||
- 软件的主要功能:500~1300字符,详细描述软件核心功能。
|
||||
- 软件的技术特点:多选标签(APP/游戏软件/教育软件等)+ 文本描述≤100字符,简述技术架构和关键技术;标签都不符合时可不选。
|
||||
|
||||
此阶段需要先停止等待用户输入;收到用户回复后,可整理为 `answers` JSON 传入申请表草稿生成。申请表字段的最终门禁在 `草稿/申请表信息.md` 生成后记录。
|
||||
|
||||
### 6. 确认代码文件选择
|
||||
|
||||
生成代码材料前,先运行候选文件分析:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/propose_code_selection.py \
|
||||
--project <项目目录> \
|
||||
--analysis 软件著作权申请资料/analysis/project.json \
|
||||
--out-dir 软件著作权申请资料/草稿
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
- `草稿/代码文件候选清单.md`:给用户看的候选说明。
|
||||
- `草稿/代码文件选择.json`:可编辑的选择文件。
|
||||
|
||||
脚本生成的候选清单只列证据,不默认选择文件。模型必须先阅读业务理解、候选文件、入口文件、页面文件和必要源码,判断哪些源码最能体现软件真实功能和运行逻辑,然后修改 `代码文件选择.json`:
|
||||
|
||||
- `selected: true` 表示抽取该文件。
|
||||
- `selected: false` 表示不抽取该文件。
|
||||
- `model_reason` 必须说明为什么选择该文件。
|
||||
|
||||
模型选择通常优先考虑前端入口、页面、核心组件、业务交互、数据请求、状态处理等能给审核员看懂软件功能的代码;如果相关前端代码不足 60 页,再补充后端服务、业务处理等相关源码。补充文件同样必须写入 `代码文件选择.json` 并由用户确认。不要默认抽取全量代码库。代码材料按完整文件原样复制,不支持只抽取某个文件的中间行段。用户确认并记录 `code-selection` 门禁后,代码抽取只读取 `代码文件选择.json` 中选中的完整文件。用户确认后运行:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/confirm_stage.py \
|
||||
--workdir 软件著作权申请资料 \
|
||||
--stage code-selection \
|
||||
--note "<用户确认内容>"
|
||||
```
|
||||
|
||||
### 7. 生成 Markdown 草稿
|
||||
|
||||
运行代码材料抽取:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/extract_code_material.py \
|
||||
--project <项目目录> \
|
||||
--analysis 软件著作权申请资料/analysis/project.json \
|
||||
--selection 软件著作权申请资料/草稿/代码文件选择.json \
|
||||
--software-name "<软件全称>" \
|
||||
--version "<版本号>" \
|
||||
--out-dir 软件著作权申请资料/草稿
|
||||
```
|
||||
|
||||
代码分页规则:
|
||||
|
||||
- 每页默认 50 行,并在 Word 中使用紧凑固定行距,尽量减少长行折行造成的页面溢出。
|
||||
- 总页数 `>= 60`:生成 `代码-前30页.md` 和 `代码-后30页.md`。
|
||||
- 总页数 `< 60` 且候选源码已用尽:只生成 `代码-全部.md`。
|
||||
- 总页数 `< 60` 但候选清单还有可补充源码:停止并要求用户在 `代码文件选择.json` 中继续选择补充文件。
|
||||
- 不为大项目生成超大“全量备份 Word”。
|
||||
- 同时生成 `代码提取清单.md` 和 `代码提取清单.json`,用于追溯代码来源。
|
||||
|
||||
生成申请表信息草稿:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/generate_application_info.py \
|
||||
--analysis 软件著作权申请资料/analysis/project.json \
|
||||
--code-manifest 软件著作权申请资料/草稿/代码提取清单.json \
|
||||
--business-context 软件著作权申请资料/草稿/业务理解.json \
|
||||
--software-name "<软件全称>" \
|
||||
--version "<版本号>" \
|
||||
--out-dir 软件著作权申请资料/草稿
|
||||
```
|
||||
|
||||
生成后必须停止,让用户检查并补全 `草稿/申请表信息.md`。字段补全并确认后运行:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/confirm_stage.py \
|
||||
--workdir 软件著作权申请资料 \
|
||||
--stage application-fields \
|
||||
--note "<用户确认内容>"
|
||||
```
|
||||
|
||||
生成操作手册草稿:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/generate_manual_draft.py \
|
||||
--analysis 软件著作权申请资料/analysis/project.json \
|
||||
--business-context 软件著作权申请资料/草稿/业务理解.json \
|
||||
--software-name "<软件全称>" \
|
||||
--version "<版本号>" \
|
||||
--out-dir 软件著作权申请资料/草稿
|
||||
```
|
||||
|
||||
操作手册草稿不得照抄用户提供的范本文案或旧项目内容,但应吸收其结构特点:先写相关文档、说明、功能特点和系统要求,再按真实页面或核心流程逐章说明操作,最后写常见问题解答和术语表。一级章节标题使用中文大写序号;相关文档章节必须是表格;功能特点和页面操作章节必须以段落展开,不用项目符号和编号列表堆信息。必须基于模型写入 `草稿/业务理解.json` 的 `manual_modules` 组织章节;`manual_sections` 只用于补充说明性段落,不应用来反复插入同一批功能模块。各功能章节必须写清页面用途、进入位置、用户看到的控件和数据、实际操作、输入限制或异常提示、操作结果和截图预留。语言要面向普通用户,说明“这个页面是干嘛的、用户怎么进入、用户点什么/填什么、操作后看到什么”,不要写代码实现、框架名称、接口封装、状态管理、异步队列等技术细节。撰写时由 agent 自行检查章节是否完整、内容是否过薄、语言是否过于技术化,并在草稿内部完成必要补写;完整草稿完成后只让用户做一次整体确认,确认前不得进入正式 Word/TXT 生成。
|
||||
|
||||
生成脚本必须同时写出 `草稿/操作手册自检记录.md` 和 `草稿/操作手册自检记录.json`。自检记录至少包含:
|
||||
|
||||
- 第 1 轮:初稿生成,检查章节完整性、截图预留、模块内容厚度和技术化表达。
|
||||
- 第 2 轮:按项目真实运行流程扩写模块说明,补足上下游衔接关系。
|
||||
- 第 3 轮:去除制式表达和 AI 味,重点检查重复句式、统一套话、空泛赞美、营销口号、过度整齐的排比和没有项目细节的正确废话。
|
||||
- 后续轮次:如果仍有问题,继续补写、去重、改写,不能把未修正的问题直接交给用户。
|
||||
|
||||
操作手册的模块写作必须从 `草稿/业务理解.json` 的行业、目标用户、核心价值、业务功能、典型操作流程和 `manual_modules` 出发。不同模块要写出各自的业务作用、入口、控件、规则和反馈,不能统一套用“进入页面、填写内容、提交按钮、查看结果”的固定句式,也不能使用“进入方式:/页面内容:/操作步骤:/操作规则:/操作结果与反馈:”这类字段标题;相近模块也要结合项目真实业务区分各自的操作目的和结果。自检时必须检查是否把同一批模块在多个章节中重复展开;如发现重复,改为每个真实页面或流程独立成章。不得把测试项目的功能名称、业务流程或示例文案写成通用规则。
|
||||
|
||||
### 8. 选择并获取截图
|
||||
|
||||
操作手册草稿完成后,先停止并让用户选择截图方式,必须给出三种选项:
|
||||
|
||||
1. Chrome DevTools MCP:适合已在浏览器中打开的 Web 项目,优先用于网页全页截图。
|
||||
2. Codex Computer Use:适合需要通过桌面应用或浏览器界面点击、切换、查看状态后截图的场景。
|
||||
3. 用户自行截图:用户自己把 PNG/JPG/JPEG/WebP 图片放入 `软件著作权申请资料/用户截图/`,agent 只负责整理和引用。
|
||||
|
||||
如果用户明确说“现在不截图”“先跳过截图”“这次不截图”,也必须记录截图方式门禁,方法填 `skip`。跳过截图不阻塞正式资料生成,但操作手册中每个核心功能模块必须保留可见的截图预留文字,例如:`【截图预留:请在此处插入“项目管理”页面或操作结果截图。】`。不要使用 HTML 注释作为截图占位,因为正式 Word 中看不到。
|
||||
|
||||
用户选择后,先记录门禁:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/confirm_stage.py \
|
||||
--workdir 软件著作权申请资料 \
|
||||
--stage screenshot-method \
|
||||
--method <chrome-devtools|computer-use|user-supplied|skip> \
|
||||
--note "<用户选择>"
|
||||
```
|
||||
|
||||
然后按用户选择检查当前能力并执行:
|
||||
|
||||
- 选择 Chrome DevTools MCP:先用工具发现能力检查当前环境是否有 `mcp__chrome_devtools__` 的 `list_pages`、`take_snapshot`、`take_screenshot`。可用时,先 `list_pages` 确认当前浏览器页面,再按页面/路由截图保存到 `软件著作权申请资料/截图/`;不可用时停止,告知用户需要重新选择截图方式或手动提供截图。
|
||||
- 选择 Codex Computer Use:先用工具发现能力检查当前环境是否有 `mcp__computer_use__` 的 `get_app_state`、`click`、`press_key`。可用时,先 `get_app_state` 查看目标应用或浏览器当前状态,再按操作手册需要导航和截图;如果当前 Computer Use 只能返回会话内截图而不能直接保存图片文件,则说明限制,并让用户改选 Chrome DevTools MCP 或把截图放入 `用户截图/`。
|
||||
- 选择用户自行截图:创建 `软件著作权申请资料/用户截图/`,提示用户把截图文件放入该目录;用户放入后运行下面的整理命令,把图片复制到 `软件著作权申请资料/截图/` 并生成 `截图清单.json`。
|
||||
- 选择跳过截图:不运行截图工具,继续保留操作手册中的可见截图预留文字;在生成报告中说明用户选择暂不截图,正式操作手册已预留截图位置。
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/capture_screenshots.py \
|
||||
--manual-dir 软件著作权申请资料/用户截图 \
|
||||
--out-dir 软件著作权申请资料/截图
|
||||
```
|
||||
|
||||
截图成功后,把截图引用补入 `草稿/操作手册.md`;截图失败或用户选择暂不提供截图时,继续生成带截图预留位的文字版,并在报告中说明“操作手册截图未生成或未插入,已保留截图预留位置”。
|
||||
|
||||
### 9. 用户确认 Markdown
|
||||
|
||||
生成 Word 前,必须让用户确认 `软件著作权申请资料/草稿/` 下的 Markdown。
|
||||
|
||||
重点检查:
|
||||
|
||||
- 软件名称和版本号是否一致
|
||||
- 代码材料前30页、后30页页眉软件名称是否与 `申请表信息.md` 的“软件全称”一致
|
||||
- 代码材料前30页、后30页页眉版本号是否与 `申请表信息.md` 的“版本号”一致
|
||||
- 操作手册 Word 页眉是否与代码材料页眉一致,均使用 `申请表信息.md` 的“软件全称”和“版本号”
|
||||
- `业务理解.md` 是否准确反映软件真实业务、行业和目标用户
|
||||
- `申请表信息.md` 中“待用户确认”的字段是否已确认
|
||||
- 代码材料是否只来自用户确认的完整文件
|
||||
- 操作手册是否符合审核员阅读场景,普通读者是否能看懂模块用途和操作方式
|
||||
- 操作手册每个章节是否有段落内容,核心模块是否写清模块用途、操作过程和结果反馈,是否避免过度技术化语言
|
||||
- 截图是否正确;若用户跳过截图,正式操作手册是否保留可见截图预留位置
|
||||
|
||||
用户确认后,必须记录 `markdown` 门禁;未记录时不得生成正式 Word/TXT。
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/confirm_stage.py \
|
||||
--workdir 软件著作权申请资料 \
|
||||
--stage markdown \
|
||||
--note "<用户确认内容>"
|
||||
```
|
||||
|
||||
### 10. 生成正式 Word/TXT
|
||||
|
||||
用户确认后运行:
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/build_docx_from_md.py \
|
||||
--workdir 软件著作权申请资料 \
|
||||
--software-name "<软件全称>" \
|
||||
--version "<版本号>"
|
||||
```
|
||||
|
||||
正式生成脚本必须重新读取 `草稿/申请表信息.md` 中已确认的“软件全称”和“版本号”,并用它们生成正式资料文件名、代码 Word 页眉和操作手册 Word 页眉。操作手册页眉必须与代码材料页眉格式一致:左侧为“软件全称 版本号”,右侧为“第 <页码> 页”。若命令参数 `--software-name` / `--version` 与申请表字段不同,以申请表字段为准,并在 `正式资料/生成报告.md` 中记录提示。
|
||||
|
||||
输出:
|
||||
|
||||
- `正式资料/申请表信息.txt`
|
||||
- 代码达到或超过 60 页:
|
||||
- `正式资料/<软件全称>-代码(前30页).docx`
|
||||
- `正式资料/<软件全称>-代码(后30页).docx`
|
||||
- 代码不足 60 页:
|
||||
- `正式资料/<软件全称>-代码(全部).docx`
|
||||
- `正式资料/<软件全称>_操作手册.docx`
|
||||
- `正式资料/生成报告.md`
|
||||
|
||||
### 11. 三轮验证
|
||||
|
||||
至少执行三轮验证并修复发现的问题:
|
||||
|
||||
1. 文件完整性:目标 Word/TXT 是否存在且非空。
|
||||
2. 代码真实性:抽样检查代码片段能回溯到项目源码。
|
||||
3. 业务真实性:申请表和操作手册中的行业、目标用户、主要功能、操作流程能回溯到 `业务理解.md` 和项目文档。
|
||||
4. 一致性和格式:软件名称、版本号、页数规则、申请表字段、操作手册标题和截图引用是否一致。
|
||||
|
||||
可用命令:
|
||||
|
||||
```bash
|
||||
python3 -m py_compile ${CLAUDE_SKILL_DIR}/scripts/*.py
|
||||
bash ${CLAUDE_SKILL_DIR}/vendor/docx-toolkit/scripts/docx_preview.sh <生成的docx>
|
||||
```
|
||||
|
||||
完整 DOCX 环境检查和安装必须直接恢复/构建 `${CLAUDE_SKILL_DIR}/vendor/docx-toolkit/scripts/dotnet/DocxToolkit.Cli/DocxToolkit.Cli.csproj`,不要对 `vendor/docx-toolkit/scripts/dotnet` 目录或 `.slnx` 文件执行隐式 restore/build。
|
||||
|
||||
如果 `环境检查.md` 或 `${CLAUDE_SKILL_DIR}/vendor/docx-toolkit/scripts/env_check.sh` 显示 `.NET SDK` 缺失,说明完整 DOCX OpenXML 校验环境未就绪。用户明确选择不安装并记录 `environment` 门禁后,继续生成 Markdown、TXT 和基础 DOCX,并在报告中说明当前使用兜底路径。
|
||||
|
||||
## 何时询问用户
|
||||
|
||||
以下场景必须询问并停止,等待用户输入后再继续:
|
||||
|
||||
- 多个项目候选目录需要选择。
|
||||
- 启动环境检查发现完整 DOCX 环境缺失时,询问用户是否安装完整环境。
|
||||
- 业务理解草稿生成后,请用户确认软件用途、行业、目标用户、核心功能和申请口径。
|
||||
- 软件全称、著作权人、日期、硬件/系统环境等登记字段需要确认。
|
||||
- 代码文件候选清单生成后,需要用户确认或修改 `代码文件选择.json`。
|
||||
- 操作手册截图前,需要用户在 Chrome DevTools MCP、Codex Computer Use、用户自行截图三种方式中选择一种;选择后再检查对应工具是否可用。
|
||||
- 用户是否确认 Markdown 草稿并进入 Word 生成。
|
||||
@@ -0,0 +1,3 @@
|
||||
display_name: 软著申请资料生成
|
||||
short_description: 从真实项目生成软著申请 Word 和 TXT 材料
|
||||
default_prompt: 读取当前目录中的项目,生成软件著作权申请资料草稿,确认后输出 Word 和 TXT。
|
||||
@@ -0,0 +1,61 @@
|
||||
# 申请表信息字段
|
||||
|
||||
按官网实际表单顺序和字段名:
|
||||
|
||||
1. 软件全称
|
||||
2. 软件简称(可选)
|
||||
3. 版本号
|
||||
4. 软件分类(应用软件/嵌入式软件/中间件/系统软件/其他)
|
||||
5. 开发完成日期(YYYY-MM-DD)
|
||||
6. 开发方式(单独开发/合作开发/委托开发/下达任务开发)
|
||||
7. 软件说明(原创 / 修改(含翻译软件、合成软件))
|
||||
8. 发表状态(已发表/未发表)
|
||||
9. 首次发表日期(已发表时填写,YYYY-MM-DD)
|
||||
10. 著作权人(复合字段:国家/省市/类型[自然人/法人]/姓名/证件类型/证件号)
|
||||
11. 权利范围(全部权利/部分权利)
|
||||
12. 权利取得方式(原始取得/继受取得)
|
||||
13. 开发的硬件环境(≤50字符)
|
||||
14. 运行的硬件环境(≤50字符)
|
||||
15. 开发该软件的操作系统(≤50字符)
|
||||
16. 软件开发环境 / 开发工具(≤50字符,格式:开发环境: xxx/开发工具: xxx)
|
||||
17. 该软件的运行平台 / 操作系统(≤50字符)
|
||||
18. 软件运行支撑环境 / 支持软件(≤50字符)
|
||||
19. 编程语言(预设按钮选择 + 自定义输入≤120字符)
|
||||
20. 源程序量(纯数字,单位行,指全部源程序总行数)
|
||||
21. 开发目的(≤50字符,不能只写软件名称)
|
||||
22. 面向领域 / 行业(≤50字符)
|
||||
23. 软件的主要功能(500~1300字符)
|
||||
24. 软件的技术特点(多选标签 + 文本描述≤100字符;标签:APP/游戏软件/教育软件/金融软件/医疗软件/地理信息软件/云计算软件/信息安全软件/大数据软件/人工智能软件/VR软件/5G软件/小程序/物联网软件/智慧城市软件,都不符合时可不选)
|
||||
25. 页数(代码鉴别材料实际页数)
|
||||
|
||||
## 字段来源与填写口径
|
||||
|
||||
- 软件全称、版本号、著作权人、日期:由用户确认。
|
||||
- 软件全称必须显式确认;正式资料文件名、代码 Word 页眉、操作手册标题和正文中的软件名称均以申请表信息中的"软件全称"为准。
|
||||
- 软件简称:可选;如有常用简称则填写。
|
||||
- 版本号必须显式确认;如果项目配置中的版本号小于 V1.0,需提醒用户软著首次提交通常写 V1.0,并让用户确认填写 V1.0 还是项目当前版本号。
|
||||
- 软件分类:默认选"应用软件"。
|
||||
- 开发完成日期和首次发表日期:必须使用 YYYY-MM-DD 格式。
|
||||
- 开发方式:默认"单独开发",多人合作项目选"合作开发"。
|
||||
- 软件说明:默认"原创"。
|
||||
- 发表状态:用户确认已发表或未发表;已发表需附首次发表日期。
|
||||
- 编程语言、源程序量、功能模块、技术特点:根据项目分析生成。编程语言官网为预设按钮选择 + 自定义输入(≤120字符),预设选项包括 Assembly language、C、C#、C++、Delphi/Object Pascal、Go、HTML、Java、JavaScript、MATLAB、Objective-C、PHP、PL/SQL、Perl、Python、R、Ruby、SQL、Swift、Visual Basic、Visual Basic .Net。
|
||||
- 源程序量:只填纯数字(不含"行"字),指登记软件全部源程序的总行数(非仅代码材料抽取行数)。
|
||||
- 软件开发环境 / 开发工具:≤50字符,格式 `开发环境: <OS>/开发工具: <IDE>`,例如 `开发环境: Windows 11/开发工具: Visual Studio Code`;不要填写 React、Next.js、Vite、TypeScript 等技术栈。
|
||||
- 开发该软件的操作系统:≤50字符,填写实际开发电脑的操作系统版本。
|
||||
- 该软件的运行平台 / 操作系统:≤50字符,填写软件运行所在的操作系统或浏览器环境。
|
||||
- 软件运行支撑环境 / 支持软件:≤50字符,直接列出运行依赖(如 Node.js、npm、浏览器),不加格式前缀。
|
||||
- 开发的硬件环境:≤50字符,优先读取当前电脑 CPU、内存、硬盘配置作为建议值。
|
||||
- 运行的硬件环境:≤50字符,默认可沿用开发硬件建议值,也可按实际运行设备填写。
|
||||
- 开发目的:≤50字符,用一句话说明软件开发目的,不能只写软件名称。
|
||||
- 面向领域 / 行业:≤50字符。
|
||||
- 软件的主要功能:500~1300字符,详细描述软件核心功能。
|
||||
- 软件的技术特点:多选标签(APP/游戏软件/教育软件等)+ 文本描述≤100字符;标签都不符合时可不选。
|
||||
|
||||
## 一致性要求
|
||||
|
||||
- 软件全称和版本号必须与代码材料、操作手册一致。
|
||||
- 正式代码 Word 页眉软件名称必须与申请表信息中的“软件全称”一致,生成 Word 时以申请表软件全称为准。
|
||||
- 正式代码 Word 页眉版本号必须与申请表信息中的“版本号”一致,生成 Word 时以申请表版本号为准。
|
||||
- 主要功能必须来自当前项目,不得沿用范本中的旧项目描述。
|
||||
- `待用户确认` 字段在正式输出前应尽量替换为确认值;如仍存在,必须写入生成报告。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 业务理解规则
|
||||
|
||||
申请表信息和操作手册不能只根据代码结构泛泛生成,必须先理解软件业务。
|
||||
|
||||
## 证据收集
|
||||
|
||||
先用脚本收集证据,输出 `草稿/业务理解证据.md/json` 和 `草稿/业务理解模型稿模板.json`。证据通常包括:
|
||||
|
||||
- `README.md`
|
||||
- `docs/*PRD*.md`
|
||||
- `docs/*BRD*.md`
|
||||
- `docs/*ARCHITECTURE*.md`
|
||||
- 产品说明、需求文档、设计文档
|
||||
- 前端页面标题、按钮文案、路由、核心组件名
|
||||
- 后端 API 路由和模型名称
|
||||
|
||||
这些只是候选证据,不代表最终行业、功能和手册结构。
|
||||
|
||||
## 输出业务理解草稿
|
||||
|
||||
模型必须阅读证据和必要源码,自行判断应该抽取哪些业务信息,再生成业务理解模型稿 JSON。不得用关键字表或固定模板决定行业、功能和结构。
|
||||
|
||||
模型稿经脚本校验后生成 `草稿/业务理解.md` 和 `草稿/业务理解.json`,至少包含:
|
||||
|
||||
- 产品定位
|
||||
- 面向领域 / 行业
|
||||
- 目标用户
|
||||
- 用户痛点和核心价值
|
||||
- 主要业务功能
|
||||
- 典型操作流程
|
||||
- 操作手册结构建议
|
||||
- 操作手册页面/流程模块,必须说明每个真实页面或核心流程的使用场景、进入位置、用户可见元素、用户动作、输入/状态规则、结果反馈和截图预留
|
||||
- 申请表建议口径
|
||||
- 证据来源
|
||||
- 待用户确认项
|
||||
|
||||
## 外部调研
|
||||
|
||||
如果项目材料不足、业务类型较新,或用户明确希望参考竞品,可联网搜索相近产品和行业资料。
|
||||
|
||||
外部调研只用于帮助理解行业表达,不能编造项目不存在的功能。需要把调研结论写入业务理解草稿,并区分“项目证据”和“行业参考”。
|
||||
|
||||
## 生成材料约束
|
||||
|
||||
- `申请表信息.md/txt` 的开发目的、行业、主要功能、技术特点必须优先来自业务理解。
|
||||
- `操作手册.md/docx` 的说明、功能特点、系统要求、核心页面/流程、常见问题、术语表和章节结构必须优先来自模型确认后的业务理解。
|
||||
- 操作手册不应只生成抽象“功能列表”。模型应把路由、页面、按钮、输入框、列表、弹窗、状态提示、额度或权限规则等用户可见证据整理到 `manual_modules`,供脚本按通用操作手册骨架排版。最终成稿应是段落化用户手册,不是“进入方式/页面内容/操作步骤/结果反馈”的字段列表。
|
||||
- 如果缺少 `manual_modules`、`system_requirements`、`faq` 或 `glossary`,应回到业务理解阶段补充真实内容;脚本不得用分类模板兜底生成。
|
||||
- 如果业务理解仍不充分,先提示用户补充产品说明,而不是直接生成泛泛描述。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 代码抽取规则
|
||||
|
||||
## 选择方式
|
||||
|
||||
脚本只生成候选源码清单,不默认决定抽取文件。模型需要先理解项目业务、页面入口和源码职责,再决定抽取哪些文件或行段,并在 `代码文件选择.json` 中填写:
|
||||
|
||||
- `selected`
|
||||
- `start_line`
|
||||
- `end_line`
|
||||
- `model_reason`
|
||||
|
||||
选择时通常优先考虑审核员能看懂软件功能和运行逻辑的源码,例如入口、页面、业务组件、数据交互、状态处理、业务服务等。具体选择由模型根据项目实际判断,不能用固定路径规则直接拍板。
|
||||
|
||||
## 排除项
|
||||
|
||||
排除以下内容:
|
||||
|
||||
- `node_modules`
|
||||
- `dist`、`build`、`.next`、`.nuxt`、`coverage`
|
||||
- lock 文件
|
||||
- 图片、字体、二进制文件
|
||||
- sourcemap、minified 文件
|
||||
- 自动生成文件
|
||||
- 过短且无业务意义的配置文件
|
||||
|
||||
## 真实性要求
|
||||
|
||||
- 保留原始代码文本。
|
||||
- 可添加文件路径标记用于追溯。
|
||||
- 不改写业务逻辑。
|
||||
- 不使用 AI 补齐代码。
|
||||
|
||||
## 用户确认要求
|
||||
|
||||
- 代码抽取前必须先生成 `代码文件候选清单.md` 和 `代码文件选择.json`。
|
||||
- 模型必须先填写抽取选择和 `model_reason`,再让用户确认或手动调整 `selected`。
|
||||
- 用户可以通过 `start_line` / `end_line` 只抽取某个文件的指定行段。
|
||||
- 抽取脚本必须以 `代码文件选择.json` 为准,不能绕过确认步骤直接抽全量代码库。
|
||||
- `代码提取清单.md` 必须记录每个文件的抽取行段,便于回溯。
|
||||