From 67781961e011dab90cc6d0c90041a6bee640270b Mon Sep 17 00:00:00 2001 From: Xu Zhipei Date: Wed, 29 Jul 2026 21:52:46 +0800 Subject: [PATCH 1/4] feat(cli): add async server wait workflow --- docs/.vitepress/config/en.ts | 1 + docs/.vitepress/config/zh.ts | 1 + docs/en/commands/index.md | 1 + docs/en/commands/inspect.md | 2 +- docs/en/commands/run.md | 2 + docs/en/commands/wait.md | 29 ++ docs/en/guide/advanced/fast-startup.md | 292 +++--------------- docs/zh/commands/index.md | 1 + docs/zh/commands/inspect.md | 2 +- docs/zh/commands/run.md | 2 + docs/zh/commands/wait.md | 29 ++ docs/zh/guide/advanced/fast-startup.md | 288 +++-------------- src/commands/inspect/inspectUtils.ts | 16 + .../instructions/instructionsUtils.ts | 13 +- src/commands/run/run.rest-errors.test.ts | 25 +- src/commands/run/run.rest-fallback.test.ts | 5 + src/commands/run/run.rest-primary.test.ts | 37 ++- src/commands/run/run.ts | 98 ++++++ .../shared/clientSurfaceAttachment.test.ts | 28 ++ .../shared/clientSurfaceAttachment.ts | 5 +- src/commands/wait/index.ts | 28 ++ src/commands/wait/wait.test.ts | 95 ++++++ src/commands/wait/wait.ts | 199 ++++++++++++ src/index.ts | 3 + src/transport/http/routes/apiRoutes.test.ts | 30 ++ .../http/routes/inspectHelpers.test.ts | 30 ++ src/transport/http/routes/inspectHelpers.ts | 14 + src/transport/http/routes/inspectRoutes.ts | 58 +++- 28 files changed, 821 insertions(+), 513 deletions(-) create mode 100644 docs/en/commands/wait.md create mode 100644 docs/zh/commands/wait.md create mode 100644 src/commands/wait/index.ts create mode 100644 src/commands/wait/wait.test.ts create mode 100644 src/commands/wait/wait.ts create mode 100644 src/transport/http/routes/inspectHelpers.test.ts diff --git a/docs/.vitepress/config/en.ts b/docs/.vitepress/config/en.ts index 75ab2f87..4b76ff2e 100644 --- a/docs/.vitepress/config/en.ts +++ b/docs/.vitepress/config/en.ts @@ -194,6 +194,7 @@ function sidebar(): DefaultTheme.Sidebar { { text: 'instructions', link: '/commands/instructions' }, { text: 'inspect', link: '/commands/inspect' }, { text: 'run', link: '/commands/run' }, + { text: 'wait', link: '/commands/wait' }, { text: 'cli-setup', link: '/commands/cli-setup' }, { text: 'auth', link: '/commands/auth' }, ], diff --git a/docs/.vitepress/config/zh.ts b/docs/.vitepress/config/zh.ts index 4774a51e..a869cdf2 100644 --- a/docs/.vitepress/config/zh.ts +++ b/docs/.vitepress/config/zh.ts @@ -204,6 +204,7 @@ function sidebar(): DefaultTheme.Sidebar { { text: 'instructions', link: '/zh/commands/instructions' }, { text: 'inspect', link: '/zh/commands/inspect' }, { text: 'run', link: '/zh/commands/run' }, + { text: 'wait', link: '/zh/commands/wait' }, { text: 'cli-setup', link: '/zh/commands/cli-setup' }, { text: 'auth', link: '/zh/commands/auth' }, ], diff --git a/docs/en/commands/index.md b/docs/en/commands/index.md index d2203ae6..1335391b 100644 --- a/docs/en/commands/index.md +++ b/docs/en/commands/index.md @@ -79,6 +79,7 @@ npx -y @1mcp/agent run context7/query-docs --args '{"libraryId":"/mongodb/docs", - **[instructions](/commands/instructions)** - Print the CLI playbook and current servers - **[inspect](/commands/inspect)** - Discover tools and inspect schemas - **[run](/commands/run)** - Execute a tool call +- **[wait](/commands/wait)** - Wait for configured static servers to connect - **[cli-setup](/commands/cli-setup)** - Install Codex or Claude bootstrap files - **[auth](/commands/auth)** - Manage authentication profiles for secured servers diff --git a/docs/en/commands/inspect.md b/docs/en/commands/inspect.md index cd626438..42ae75c0 100644 --- a/docs/en/commands/inspect.md +++ b/docs/en/commands/inspect.md @@ -33,7 +33,7 @@ Depending on the target, `inspect` can: - List the exposed tools for a server when the target is `` - Show a readable tool schema summary when the target is `/` -When supported, `inspect` uses the fast `/api/inspect` endpoint first and falls back to the MCP protocol when needed. +When supported, `inspect` uses the authenticated `/api/v1/inspect` endpoint first and falls back to the MCP protocol when needed. It reports startup states for configured static servers before the first capability snapshot; use [`wait`](./wait.md) when a script must wait for `connected`. This is the command that turns the broad inventory from `instructions` into a scoped view. First inspect one server, then inspect one tool, and only then move to execution. diff --git a/docs/en/commands/run.md b/docs/en/commands/run.md index 86c03cc8..44ec6320 100644 --- a/docs/en/commands/run.md +++ b/docs/en/commands/run.md @@ -35,6 +35,8 @@ The `run` command is the execution step in the CLI workflow: `run` is intentionally the last step. The recommended flow is to discover broadly with `instructions`, narrow with `inspect`, confirm the exact tool schema with `inspect /`, and only then invoke the tool. +Before a REST-to-MCP fallback, `run` checks the client-facing inspect status. A loading backend returns `server_loading` and `1mcp wait ` rather than receiving an early MCP invocation. Failed or cancelled backends return `server_unavailable`; an OAuth-gated backend returns authorization guidance. + ## Options ### Target and Discovery diff --git a/docs/en/commands/wait.md b/docs/en/commands/wait.md new file mode 100644 index 00000000..82e72af5 --- /dev/null +++ b/docs/en/commands/wait.md @@ -0,0 +1,29 @@ +--- +title: Wait Command - Wait for Static MCP Servers +description: Wait for configured static MCP servers to become connected before invoking tools. +--- + +# Wait Command + +Wait for enabled configured static MCP servers to become connected through the authenticated `/api/v1/inspect` status contract. + +```bash +npx -y @1mcp/agent wait [server] [options] +``` + +Without a server, `wait` covers all matching configured static servers. Templates and disabled servers are excluded. A template target returns `server_not_load_tracked`; an unknown target returns `server_not_found`. + +## Options + +- **`[server]`** - One configured static server to wait for +- **`--timeout `** - Positive timeout in milliseconds, default `30000` +- **`--url, -u `** - Override the auto-detected runtime URL +- **`--context `** - Use a named Runtime Target Context +- **`--preset`, `--tag-filter`, `--tags`** - Apply the same client-facing filters as `inspect` +- **`--format `** - Select success output format + +## Behavior + +Success requires every requested server to report `connected` and `available`. `failed`, `cancelled`, `awaiting_oauth`, and unavailable terminal states stop immediately with a structured nonzero error. A timeout includes the last observed states and a recovery command. Waiting never cancels background loading. + +Use `wait` before `run` when a script must depend on a newly started backend. `run` also checks inspect status before its REST-to-MCP fallback and returns `server_loading` with the same recovery command when the backend is still loading. diff --git a/docs/en/guide/advanced/fast-startup.md b/docs/en/guide/advanced/fast-startup.md index 94c8e00a..85fad952 100644 --- a/docs/en/guide/advanced/fast-startup.md +++ b/docs/en/guide/advanced/fast-startup.md @@ -1,289 +1,83 @@ --- title: Fast Startup - Async Server Loading -description: Learn how to enable async server loading in 1MCP. Start immediately with real-time capability updates for better performance. -head: - - ['meta', { name: 'keywords', content: 'fast startup,async loading,performance,speed optimization' }] - - ['meta', { property: 'og:title', content: '1MCP Fast Startup Guide' }] - - [ - 'meta', - { - property: 'og:description', - content: 'Enable async server loading for faster 1MCP startup with real-time updates.', - }, - ] +description: Choose synchronous or async MCP server startup and monitor safe client-facing status. --- # Fast Startup: Async Server Loading -Get your 1MCP agent running instantly with real-time capability updates, even when some servers take time to connect. +Async loading makes the HTTP listener available before configured static MCP servers finish connecting. It does not make every MCP client safe to use an incomplete capability catalog. -## What's This About? +## Default and Compatibility -When you start 1MCP, it needs to connect to all your configured MCP servers. Previously, if even one server was slow or unreachable, your entire 1MCP instance would be stuck waiting. Now, 1MCP starts immediately and connects to servers in the background, sending real-time `listChanged` notifications to clients as capabilities become available. +Synchronous startup is the default. It waits to publish a complete initial catalog and is the safe choice unless client behavior is known. -## The Problem We Solved +| Client behavior | Recommended startup | Reason | +| --- | --- | --- | +| Reads the catalog once and never refreshes | Synchronous (default) | It requires the complete initial catalog. | +| Handles capability-list change notifications and retries discovery | Async is suitable | It can reconcile after a new snapshot is published. | +| Is gated from using the catalog until loading completes | Async is suitable | The deployment gate protects its first catalog read. | +| Unknown or mixed compatibility | Synchronous (default) | Notification support is unverified. | -**Before**: - -- 1MCP waits for ALL servers before starting -- One slow server = entire system stuck -- Network issues block your workflow - -**Now**: - -- 1MCP starts in under 1 second -- Servers connect in the background -- Real-time notifications as capabilities become available -- You can work immediately with available servers - -## How It Works - -```mermaid -graph TD - A[Start 1MCP] --> B[HTTP Server Ready <1s] - B --> C[Background: Connect to MCP Servers] - C --> D[Server 1: Ready ✅] - C --> E[Server 2: Loading ⏳] - C --> F[Server 3: Failed ❌] - D --> G[Full Functionality Available] - E --> G - F --> H[Retry in Background] - H --> G -``` - -## Server States - -| State | Icon | What It Means | What You Can Do | -| ------------------ | ---- | ------------------------------- | ---------------------------- | -| **Ready** | ✅ | Server connected successfully | Full functionality available | -| **Loading** | ⏳ | Server connecting in background | Wait or use other servers | -| **Failed** | ❌ | Connection failed, will retry | Check server config/network | -| **Awaiting OAuth** | 🔐 | Needs authorization | Complete OAuth flow | - -## Monitor Your Servers - -### Quick Health Check - -```bash -curl http://localhost:3000/health/mcp -``` - -### Individual Server Status +Enable async loading only when early HTTP availability is worth this contract: ```bash -curl http://localhost:3000/health/mcp/context7 -``` - -## Benefits for You - -### ⚡ Instant Startup - -- 1MCP ready in <1 second regardless of server count -- No more waiting for slow connections -- Start working immediately - -### 🛡️ Resilient Operation - -- One failed server doesn't break everything -- Automatic retries with smart backoff -- Graceful handling of network issues - -### 📊 Full Visibility - -- Real-time status of all servers -- Detailed error messages when things fail -- Progress tracking for long connections - -## Enabling Async Loading - -Async loading is an opt-in feature that's disabled by default to maintain backward compatibility. - -### CLI Flag - -```bash -# Enable async loading with CLI flag npx -y @1mcp/agent --config mcp.json --enable-async-loading ``` -### Environment Variable +## Catalog Publication -```bash -# Enable via environment variable -export ONE_MCP_ENABLE_ASYNC_LOADING=true -npx -y @1mcp/agent --config mcp.json -``` +In async mode, the early catalog can be empty. 1MCP connects static backends in the background, builds the next capability snapshot, then publishes that snapshot atomically. It does not progressively expose one newly connected server at a time. -### Real-Time Notifications - -When async loading is enabled, clients receive `listChanged` notifications as servers become ready: +Compatible clients must process capability-list change notifications and retry discovery. A client that ignores those notifications can retain the empty or prior catalog until it reconnects. ```mermaid sequenceDiagram participant Client participant 1MCP - participant Server1 - participant Server2 + participant Backends Client->>1MCP: Connect - 1MCP-->>Client: Immediate connection (empty capabilities) - - par Background Loading - 1MCP->>Server1: Start server - Server1-->>1MCP: Ready with tools - 1MCP-->>Client: listChanged notification (tools) - and - 1MCP->>Server2: Start server - Server2-->>1MCP: Ready with resources - 1MCP-->>Client: listChanged notification (resources) - end + 1MCP-->>Client: Initial catalog (possibly empty) + 1MCP->>Backends: Connect configured static servers + Backends-->>1MCP: Loading cycle completes + 1MCP-->>Client: Publish atomic catalog and list-change notification + Client->>1MCP: Retry discovery ``` -**Benefits:** - -- **Progressive Discovery**: New capabilities appear in real-time -- **Batched Notifications**: Multiple changes grouped to prevent spam -- **Better UX**: No need to manually refresh or reconnect - -The `async-min-servers` setting is a startup readiness gate. During each loading cycle, 1MCP keeps the previous Capability Snapshot visible until every configured backend is connected or marked unavailable, then publishes the completed snapshot atomically. Inspection endpoints such as `/api/tools` query the Capability Catalog without forcing all servers to reconnect or refresh. - -## Configuration - -You can customize the async loading behavior, such as timeouts and retry logic, in the `loading` section of your JSON configuration file. - -For a complete list of options, see the **[Configuration Deep Dive](/guide/essentials/configuration#loading-section-async-loading)**. - -## Health Check API - -Check the status of your MCP servers through simple HTTP endpoints. +## Client-Facing Status -### Overall Status: `/health/mcp` - -Get a complete overview of all your servers: - -```bash -curl http://localhost:3000/health/mcp -``` - -**Example Response:** - -```json -{ - "loading": { - "isComplete": false, // Still connecting to some servers - "successRate": 66.7, // 2 out of 3 servers connected - "averageLoadTime": 2500 // Average connection time in ms - }, - "summary": { - "total": 3, // Total configured servers - "ready": 2, // Servers ready to use - "loading": 1, // Servers still connecting - "failed": 0 // Servers that failed - }, - "servers": { - "ready": ["context7", "magic"], // Working servers - "loading": ["sequential"] // Still connecting - } -} -``` - -### Individual Server: `/health/mcp/:serverName` - -Check a specific server: +Use the authenticated `/api/v1/inspect` endpoint or its CLI equivalent for client-facing status. It lists configured static servers before the first atomic capability snapshot. Known unavailable static servers return a normal inspect result with no tools; unknown names remain not found. ```bash -curl http://localhost:3000/health/mcp/context7 +1mcp inspect +1mcp inspect filesystem +1mcp wait +1mcp wait filesystem --timeout 60000 ``` -**Example Response:** - -```json -{ - "name": "context7", - "state": "ready", // Current status - "duration": 2300, // Time to connect (ms) - "retryCount": 1, // Number of connection attempts - "message": "Successfully connected" -} -``` - -## Common Scenarios - -### Network Timeout - -When a server takes too long to connect: - -``` -Server "context7" → Loading → Timeout (30s) → Failed → Retry in background (60s) -``` - -### OAuth Authorization - -Some servers need you to authorize first: +`wait` only tracks enabled configured static servers. It excludes templates and disabled servers, never cancels background loading, and stops immediately for `failed`, `cancelled`, or `awaiting_oauth` states. `run` checks this client-facing status before falling back to MCP, so it returns a recovery command while a backend is loading instead of attempting an unsafe early invocation. -``` -Server "github" → Loading → Awaiting OAuth → Visit auth URL → Ready -``` +| State | Meaning | Action | +| --- | --- | --- | +| `pending` / `loading` | Startup is still in progress | `1mcp wait ` | +| `failed` / `cancelled` | No usable backend is available | Check configuration or restart the backend | +| `awaiting_oauth` | Provider authorization is required | Complete the OAuth flow shown by inspect | +| `connected` | The backend is callable | Inspect or run tools | -### Partial Availability +## Health Endpoints -When some servers work and others don't: +`/health/ready` and `/health/mcp` answer different questions: -``` -3 servers configured: -✅ context7 (ready) -✅ magic (ready) -❌ sequential (failed) +- `/health/ready` reports whether runtime configuration is ready to accept HTTP traffic. It is not a claim that every MCP backend has completed startup. +- `/health/mcp` is the operational backend-progress view, including loading and retry detail. -Result: 1MCP works with 2/3 servers, keeps retrying the failed one +```bash +curl http://localhost:3050/health/ready +curl http://localhost:3050/health/mcp ``` -## What You'll Notice - -### Faster Startup ⚡ - -- 1MCP starts in under 1 second -- No more waiting for slow connections -- You can start working immediately - -### Better Reliability 🛡️ - -- Individual server failures don't break everything -- Failed servers retry automatically in the background -- Clear visibility into what's working and what's not - -### Monitoring Made Easy 📊 - -- Check `/health/mcp` anytime to see status -- Get detailed info about individual servers -- Real-time progress as servers connect - -## Troubleshooting - -### "Some servers are still loading" - -**Normal behavior.** 1MCP starts fast and connects servers in background. Check `/health/mcp` to see progress. - -### "Server keeps failing to connect" - -1. Check your network connection -2. Verify server configuration in your `mcp.json` -3. Check if the server requires OAuth authorization -4. Look at `/health/mcp/servername` for detailed error messages - -### "Everything seems slow" - -1. Check how many servers you're loading simultaneously (`maxConcurrentLoads`) -2. Consider increasing timeout values for slow networks -3. Some servers might be rate-limiting your connections - -### "1MCP won't start at all" - -This usually means a configuration problem, not a server connection issue: +Use the inspect API or CLI for an authenticated caller deciding whether to invoke a tool. Use health endpoints for monitoring. -1. Check your `mcp.json` file syntax -2. Verify file permissions -3. Check the 1MCP logs for specific errors +## Configuration Notes -::: tip -The async loading only affects MCP server connections. If 1MCP itself won't start, it's likely a configuration or permission issue. -::: +Async loading remains opt-in. This page describes the runtime contract rather than deprecated-option cleanup; use current command help and the configuration reference for supported options. diff --git a/docs/zh/commands/index.md b/docs/zh/commands/index.md index 68ebe54e..258f640e 100644 --- a/docs/zh/commands/index.md +++ b/docs/zh/commands/index.md @@ -73,6 +73,7 @@ npx -y @1mcp/agent run context7/query-docs --args '{"libraryId":"/mongodb/docs", - **[instructions](/zh/commands/instructions)** - 打印 CLI playbook 和当前服务器清单 - **[inspect](/zh/commands/inspect)** - 发现工具并查看 schema - **[run](/zh/commands/run)** - 执行工具调用 +- **[wait](/zh/commands/wait)** - 等待已配置静态服务器连接 - **[cli-setup](/zh/commands/cli-setup)** - 安装 Codex 或 Claude 的引导文件 - **[auth](/zh/commands/auth)** - 管理受保护服务器的认证配置 diff --git a/docs/zh/commands/inspect.md b/docs/zh/commands/inspect.md index 5ae21961..6d469eae 100644 --- a/docs/zh/commands/inspect.md +++ b/docs/zh/commands/inspect.md @@ -18,7 +18,7 @@ npx -y @1mcp/agent inspect [target] [选项] - 在 target 为 `` 时列出该服务器的工具 - 在 target 为 `/` 时输出工具 schema 摘要 -支持时,`inspect` 会优先使用快速的 `/api/inspect` 端点,否则回退到 MCP 协议。 +支持时,`inspect` 会优先使用已认证的 `/api/v1/inspect` 端点,否则回退到 MCP 协议。它会在首个能力快照发布前报告已配置静态服务器的启动状态;脚本需要等待 `connected` 时请使用 [`wait`](./wait.md)。 它的核心作用,是把 `instructions` 提供的全局清单进一步收窄成一个 server,或者一个具体 tool 的 schema。先检查 server,再检查 tool,最后才进入执行。 diff --git a/docs/zh/commands/run.md b/docs/zh/commands/run.md index a8f39c3a..1c4c550a 100644 --- a/docs/zh/commands/run.md +++ b/docs/zh/commands/run.md @@ -20,6 +20,8 @@ npx -y @1mcp/agent run / [选项] `run` 被刻意设计成最后一步。推荐顺序是先用 `instructions` 做总览,再用 `inspect` 缩小到单个 server 和单个 tool,确认 schema 之后才真正执行。 +在 REST-to-MCP 回退前,`run` 会检查面向客户端的 inspect 状态。仍在加载的后端会返回 `server_loading` 和 `1mcp wait `,而不会收到提前 MCP 调用。失败或取消的后端返回 `server_unavailable`;OAuth 门控后端会返回授权指引。 + ## 选项 ### 目标与发现 diff --git a/docs/zh/commands/wait.md b/docs/zh/commands/wait.md new file mode 100644 index 00000000..46a68b9f --- /dev/null +++ b/docs/zh/commands/wait.md @@ -0,0 +1,29 @@ +--- +title: Wait 命令 - 等待静态 MCP 服务器 +description: 在调用工具前等待已配置的静态 MCP 服务器连接完成。 +--- + +# Wait 命令 + +通过已认证的 `/api/v1/inspect` 状态契约,等待已启用的已配置静态 MCP 服务器变为 connected。 + +```bash +npx -y @1mcp/agent wait [server] [options] +``` + +不指定服务器时,`wait` 会等待所有匹配的已配置静态服务器。模板和已禁用服务器会被排除。模板目标返回 `server_not_load_tracked`,未知目标返回 `server_not_found`。 + +## 选项 + +- **`[server]`** - 要等待的一个已配置静态服务器 +- **`--timeout `** - 正整数毫秒超时,默认 `30000` +- **`--url, -u `** - 覆盖自动检测到的运行时 URL +- **`--context `** - 使用命名 Runtime Target Context +- **`--preset`、`--tag-filter`、`--tags`** - 使用与 `inspect` 相同的面向客户端筛选 +- **`--format `** - 选择成功输出格式 + +## 行为 + +只有所有请求服务器均为 `connected` 且 `available` 时才成功。`failed`、`cancelled`、`awaiting_oauth` 和不可用终态会立即以结构化非零错误结束。超时会包含最后观察到的状态和恢复命令。等待绝不会取消后台加载。 + +当脚本依赖刚启动的后端时,请在 `run` 前使用 `wait`。`run` 也会在 REST-to-MCP 回退前检查 inspect 状态;后端仍在加载时会返回带相同恢复命令的 `server_loading`。 diff --git a/docs/zh/guide/advanced/fast-startup.md b/docs/zh/guide/advanced/fast-startup.md index a1ee2fe0..9d8764e4 100644 --- a/docs/zh/guide/advanced/fast-startup.md +++ b/docs/zh/guide/advanced/fast-startup.md @@ -1,283 +1,83 @@ --- -title: 快速启动:异步服务器加载 - 即时启动优化 -description: 学习如何在 1MCP 中启用异步服务器加载。即时启动并提供实时功能更新,提升性能和用户体验。 -head: - - ['meta', { name: 'keywords', content: '快速启动,异步加载,性能优化,速度优化,即时启动' }] - - ['meta', { property: 'og:title', content: '1MCP 快速启动指南 - 异步服务器加载' }] - - ['meta', { property: 'og:description', content: '学习如何在 1MCP 中启用异步服务器加载以提升启动速度。' }] +title: 快速启动:异步服务器加载 +description: 选择同步或异步 MCP 服务器启动,并安全监控面向客户端的状态。 --- # 快速启动:异步服务器加载 -让您的 1MCP 代理即时运行,并提供实时功能更新,即使某些服务器需要时间连接。 +异步加载会在已配置的静态 MCP 服务器完成连接前开放 HTTP 监听器;它不会让所有 MCP 客户端都能安全使用不完整的能力目录。 -## 这是关于什么的? +## 默认模式与兼容性 -当您启动 1MCP 时,它需要连接到所有已配置的 MCP 服务器。以前,如果即使一台服务器速度慢或无法访问,您的整个 1MCP 实例也会被卡住等待。现在,1MCP 会立即启动并在后台连接到服务器,随着功能可用,向客户端发送实时 `listChanged` 通知。 +同步启动是默认模式。它会等待发布完整的初始目录;除非已知客户端行为,否则应选择同步模式。 -## 我们解决的问题 +| 客户端行为 | 建议启动方式 | 原因 | +| --- | --- | --- | +| 只读取一次目录且不会刷新 | 同步(默认) | 需要完整的初始目录。 | +| 能处理能力列表变更通知并重试发现 | 可使用异步 | 可在发布新快照后重新协调。 | +| 在加载完成前不会使用目录 | 可使用异步 | 部署门禁保护首次目录读取。 | +| 兼容性未知或混合 | 同步(默认) | 通知支持尚未验证。 | -**之前**: - -- 1MCP 在启动前等待所有服务器 -- 一台慢速服务器 = 整个系统卡住 -- 网络问题会阻塞您的工作流程 - -**现在**: - -- 1MCP 在 1 秒内启动 -- 服务器在后台连接 -- 随着功能可用,提供实时通知 -- 您可以立即使用可用的服务器 - -## 工作原理 - -```mermaid -graph TD - A[启动 1MCP] --> B[HTTP 服务器在 <1s 内就绪] - B --> C[后台:连接到 MCP 服务器] - C --> D[服务器 1:就绪 ✅] - C --> E[服务器 2:加载中 ⏳] - C --> F[服务器 3:失败 ❌] - D --> G[全部功能可用] - E --> G - F --> H[后台重试] - H --> G -``` - -## 服务器状态 - -| 状态 | 图标 | 含义 | 您可以做什么 | -| -------------- | ---- | ------------------ | -------------------- | -| **就绪** | ✅ | 服务器已成功连接 | 全部功能可用 | -| **加载中** | ⏳ | 服务器正在后台连接 | 等待或使用其他服务器 | -| **失败** | ❌ | 连接失败,将重试 | 检查服务器配置/网络 | -| **等待 OAuth** | 🔐 | 需要授权 | 完成 OAuth 流程 | - -## 监控您的服务器 - -### 快速健康检查 - -```bash -curl http://localhost:3000/health/mcp -``` - -### 单个服务器状态 +只有在早期 HTTP 可用性值得采用该契约时,才启用异步加载: ```bash -curl http://localhost:3000/health/mcp/context7 -``` - -## 对您的好处 - -### ⚡ 即时启动 - -- 无论服务器数量多少,1MCP 都在 <1 秒内就绪 -- 不再等待慢速连接 -- 立即开始工作 - -### 🛡️ 弹性操作 - -- 一台失败的服务器不会破坏一切 -- 使用智能回退自动重试 -- 优雅地处理网络问题 - -### 📊 完全可见性 - -- 所有服务器的实时状态 -- 出现问题时提供详细的错误消息 -- 跟踪长连接的进度 - -## 启用异步加载 - -异步加载是一项选择性加入的功能,默认情况下禁用以保持向后兼容性。 - -### CLI 标志 - -```bash -# 使用 CLI 标志启用异步加载 npx -y @1mcp/agent --config mcp.json --enable-async-loading ``` -### 环境变量 +## 目录发布 -```bash -# 通过环境变量启用 -export ONE_MCP_ENABLE_ASYNC_LOADING=true -npx -y @1mcp/agent --config mcp.json -``` +异步模式下,早期目录可能为空。1MCP 在后台连接静态后端、构建下一份能力快照,然后原子发布该快照;不会逐台暴露刚连接的服务器。 -### 实时通知 - -启用异步加载后,客户端会在服务器就绪时收到 `listChanged` 通知: +兼容客户端必须处理能力列表变更通知并重新发现。忽略通知的客户端会一直保留空目录或旧目录,直到重新连接。 ```mermaid sequenceDiagram participant 客户端 participant 1MCP - participant 服务器1 - participant 服务器2 + participant 后端 客户端->>1MCP: 连接 - 1MCP-->>客户端: 立即连接(空功能) - - par 后台加载 - 1MCP->>服务器1: 启动服务器 - 服务器1-->>1MCP: 服务器就绪,提供工具 - 1MCP-->>客户端: listChanged 通知(工具) - and - 1MCP->>服务器2: 启动服务器 - 服务器2-->>1MCP: 服务器就绪,提供资源 - 1MCP-->>客户端: listChanged 通知(资源) - end + 1MCP-->>客户端: 初始目录(可能为空) + 1MCP->>后端: 连接已配置的静态服务器 + 后端-->>1MCP: 加载周期完成 + 1MCP-->>客户端: 原子发布目录并发送列表变更通知 + 客户端->>1MCP: 重新发现 ``` -**好处:** - -- **渐进式发现**:新功能实时出现 -- **批量通知**:将多个更改分组以防止垃圾邮件 -- **更好的用户体验**:无需手动刷新或重新连接 - -`async-min-servers` 设置是启动就绪门槛。在每个加载周期中,1MCP 会继续提供上一个能力快照 (Capability Snapshot),直到所有已配置的后端均已连接或标记为不可用,再以原子方式发布完整快照。`/api/tools` 等检查端点会查询能力目录 (Capability Catalog),而不会强制所有服务器重新连接或刷新。 - -## 配置 - -您可以在 JSON 配置文件的 `loading` 部分自定义异步加载行为,例如超时和重试逻辑。 - -有关选项的完整列表,请参阅 **[配置深入探讨](/guide/essentials/configuration#loading-section-async-loading)**。 - -## 健康检查 API - -通过简单的 HTTP 端点检查您的 MCP 服务器的状态。 +## 面向客户端的状态 -### 整体状态:`/health/mcp` - -获取所有服务器的完整概览: - -```bash -curl http://localhost:3000/health/mcp -``` - -**示例响应:** - -```json -{ - "loading": { - "isComplete": false, // 仍在连接某些服务器 - "successRate": 66.7, // 3 台服务器中有 2 台已连接 - "averageLoadTime": 2500 // 平均连接时间(毫秒) - }, - "summary": { - "total": 3, // 配置的总服务器数 - "ready": 2, // 可供使用的服务器 - "loading": 1, // 仍在连接的服务器 - "failed": 0 // 失败的服务器 - }, - "servers": { - "ready": ["context7", "magic"], // 正常工作的服务器 - "loading": ["sequential"] // 仍在连接 - } -} -``` - -### 单个服务器:`/health/mcp/:serverName` - -检查特定服务器: +使用已认证的 `/api/v1/inspect` 端点或对应 CLI 查看面向客户端的状态。它会在首个原子能力快照发布前列出已配置的静态服务器。已知但不可用的静态服务器会返回正常 inspect 结果及空工具列表;未知名称仍会返回未找到。 ```bash -curl http://localhost:3000/health/mcp/context7 +1mcp inspect +1mcp inspect filesystem +1mcp wait +1mcp wait filesystem --timeout 60000 ``` -**示例响应:** - -```json -{ - "name": "context7", - "state": "ready", // 当前状态 - "duration": 2300, // 连接时间(毫秒) - "retryCount": 1, // 连接尝试次数 - "message": "已成功连接" -} -``` - -## 常见场景 - -### 网络超时 - -当服务器连接时间过长时: - -``` -服务器 "context7" → 加载中 → 超时 (30s) → 失败 → 后台重试 (60s) -``` - -### OAuth 授权 - -某些服务器需要您先授权: +`wait` 只跟踪已启用的已配置静态服务器,会排除模板和已禁用服务器,绝不会取消后台加载;遇到 `failed`、`cancelled` 或 `awaiting_oauth` 会立即结束。`run` 会在回退到 MCP 前检查该面向客户端的状态,因此后端仍在加载时会返回恢复命令,而不会进行不安全的提前调用。 -``` -服务器 "github" → 加载中 → 等待 OAuth → 访问授权 URL → 就绪 -``` +| 状态 | 含义 | 操作 | +| --- | --- | --- | +| `pending` / `loading` | 启动仍在进行 | `1mcp wait ` | +| `failed` / `cancelled` | 没有可用后端 | 检查配置或重启后端 | +| `awaiting_oauth` | 需要提供方授权 | 完成 inspect 显示的 OAuth 流程 | +| `connected` | 后端可调用 | 检查或运行工具 | -### 部分可用性 +## 健康检查端点 -当某些服务器工作而其他服务器不工作时: +`/health/ready` 与 `/health/mcp` 回答不同的问题: -``` -配置了 3 台服务器: -✅ context7 (就绪) -✅ magic (就绪) -❌ sequential (失败) +- `/health/ready` 表示运行时配置是否已准备好接受 HTTP 流量,并不表示每个 MCP 后端都已完成启动。 +- `/health/mcp` 是面向运维的后端进度视图,包含加载与重试细节。 -结果:1MCP 使用 2/3 的服务器工作,并不断重试失败的服务器 +```bash +curl http://localhost:3050/health/ready +curl http://localhost:3050/health/mcp ``` -## 您会注意到什么 - -### 更快的启动速度 ⚡ - -- 1MCP 在 1 秒内启动 -- 不再等待慢速连接 -- 您可以立即开始工作 - -### 更好的可靠性 🛡️ - -- 单个服务器故障不会破坏一切 -- 失败的服务器会在后台自动重试 -- 清楚地了解哪些工作正常,哪些不正常 - -### 轻松监控 📊 - -- 随时检查 `/health/mcp` 以查看状态 -- 获取有关单个服务器的详细信息 -- 服务器连接时的实时进度 - -## 故障排除 - -### “某些服务器仍在加载” - -**正常行为。** 1MCP 启动速度快,并在后台连接服务器。检查 `/health/mcp` 以查看进度。 - -### “服务器不断连接失败” - -1. 检查您的网络连接 -2. 验证您的 `mcp.json` 中的服务器配置 -3. 检查服务器是否需要 OAuth 授权 -4. 查看 `/health/mcp/servername` 以获取详细的错误消息 - -### “一切似乎都很慢” - -1. 检查您同时加载的服务器数量 (`maxConcurrentLoads`) -2. 考虑为慢速网络增加超时值 -3. 某些服务器可能会限制您的连接 - -### “1MCP 根本无法启动” - -这通常表示配置问题,而不是服务器连接问题: +需要决定是否调用工具的已认证客户端应使用 inspect API 或 CLI;健康检查端点用于监控。 -1. 检查您的 `mcp.json` 文件语法 -2. 验证文件权限 -3. 检查 1MCP 日志以获取特定错误 +## 配置说明 -::: tip -异步加载仅影响 MCP 服务器连接。如果 1MCP 本身无法启动,则可能是配置或权限问题。 -::: +异步加载仍为显式启用。本页描述运行时契约,不重复旧选项的清理;请以当前命令帮助和配置参考中的受支持选项为准。 diff --git a/src/commands/inspect/inspectUtils.ts b/src/commands/inspect/inspectUtils.ts index da4c4093..a0c1d78c 100644 --- a/src/commands/inspect/inspectUtils.ts +++ b/src/commands/inspect/inspectUtils.ts @@ -58,7 +58,10 @@ export interface InspectServerInfo { type?: string; status?: string; available?: boolean; + loadTracked?: boolean; instructions?: string | null; + authorizationUrl?: string; + error?: string; tools: InspectServerToolSummary[]; totalTools?: number; hasMore?: boolean; @@ -71,6 +74,7 @@ export interface InspectServerSummary { type?: string; status?: string; available?: boolean; + loadTracked?: boolean; toolCount: number; hasInstructions: boolean; } @@ -236,6 +240,9 @@ function formatServersOutput(info: InspectServersInfo): string { if (s.available !== undefined) { serverLines.push(` available: ${s.available ? 'yes' : 'no'}`); } + if (s.loadTracked !== undefined) { + serverLines.push(` load_tracked: ${s.loadTracked ? 'yes' : 'no'}`); + } serverLines.push(` tools: ${s.toolCount}`); serverLines.push(` instructions: ${s.hasInstructions ? 'yes' : 'no'}`); } @@ -296,6 +303,15 @@ function formatServerOutput(serverInfo: InspectServerInfo): string { if (serverInfo.available !== undefined) { summaryLines.push(`available: ${serverInfo.available ? 'yes' : 'no'}`); } + if (serverInfo.loadTracked !== undefined) { + summaryLines.push(`load_tracked: ${serverInfo.loadTracked ? 'yes' : 'no'}`); + } + if (serverInfo.error) { + summaryLines.push(`error: ${serverInfo.error}`); + } + if (serverInfo.authorizationUrl) { + summaryLines.push(`authorization_url: ${serverInfo.authorizationUrl}`); + } const sections: string[] = [chalk.bold.cyan('Inspect: Server'), summaryLines.join('\n')]; diff --git a/src/commands/instructions/instructionsUtils.ts b/src/commands/instructions/instructionsUtils.ts index d76acc3d..e8a1744d 100644 --- a/src/commands/instructions/instructionsUtils.ts +++ b/src/commands/instructions/instructionsUtils.ts @@ -5,6 +5,7 @@ export interface InstructionsServerSummary { type?: string; status?: string; available?: boolean; + loadTracked?: boolean; toolCount: number; hasInstructions: boolean; } @@ -14,6 +15,7 @@ export interface InstructionsServerDetail { type?: string; status?: string; available?: boolean; + loadTracked?: boolean; toolCount: number; hasInstructions: boolean; instructions?: string | null; @@ -50,6 +52,10 @@ function formatMetadataLines(item: InstructionsServerSummary | InstructionsServe lines.push(`available: ${item.available ? 'yes' : 'no'}`); } + if (item.loadTracked !== undefined) { + lines.push(`load_tracked: ${item.loadTracked ? 'yes' : 'no'}`); + } + lines.push(`tools: ${item.toolCount}`); lines.push(`instructions: ${item.hasInstructions ? 'yes' : 'no'}`); @@ -65,9 +71,10 @@ export function formatInstructionsOutput(output: InstructionsOutput): string { '2. Review the available servers below and choose the server that matches the task.', "3. Run `1mcp inspect ` to list that server's tools.", '4. Run `1mcp inspect /` to inspect the tool schema and arguments.', - "5. Run `1mcp run / --args ''` only after inspecting the tool.", - '6. Use `--preset`, `--tags`, or `--tag-filter` to narrow the server set when needed.', - '7. If authentication is required, run `1mcp auth login --context --token ` and retry.', + '5. Run `1mcp wait ` when a configured static server is still loading.', + "6. Run `1mcp run / --args ''` only after inspecting the tool.", + '7. Use `--preset`, `--tags`, or `--tag-filter` to narrow the server set when needed.', + '8. If authentication is required, run `1mcp auth login --context --token ` and retry.', ].join('\n'), ]; diff --git a/src/commands/run/run.rest-errors.test.ts b/src/commands/run/run.rest-errors.test.ts index 632aa78a..51da6dee 100644 --- a/src/commands/run/run.rest-errors.test.ts +++ b/src/commands/run/run.rest-errors.test.ts @@ -225,6 +225,10 @@ describe('runCommand REST-first path', () => { }; } + function makeConnectedServerResponse() { + return makeRestResponse(200, { kind: 'server', server: 'runner', status: 'connected', available: true }); + } + it('does not persist hasRestEndpoint=false for transient 503 REST failures', async () => { await writeCliSessionCache(cachePath, { sessionId: 'cached-session', @@ -234,6 +238,7 @@ describe('runCommand REST-first path', () => { hasRestEndpoint: true, }); + mockFetch.mockResolvedValueOnce(makeConnectedServerResponse()); mockFetch.mockResolvedValueOnce(makeTextResponse(404, 'Not Found')); mockFetch.mockResolvedValueOnce(makeRestResponse(503, { error: 'temporarily unavailable' })); @@ -280,6 +285,7 @@ describe('runCommand REST-first path', () => { it('uses HTTP inspect schema for raw stdin mapping and skips MCP', async () => { mockFetch + .mockResolvedValueOnce(makeConnectedServerResponse()) .mockResolvedValueOnce( makeRestResponse(200, { kind: 'tool', @@ -341,7 +347,7 @@ describe('runCommand REST-first path', () => { expect(output.join('')).toContain('rest-stdin-result'); }); - it('skips REST entirely when hasRestEndpoint is false', async () => { + it('checks status even when hasRestEndpoint is false, then preserves MCP fallback', async () => { await writeCliSessionCache(cachePath, { sessionId: 'cached-session', serverUrl: 'http://127.0.0.1:3050/mcp', @@ -351,6 +357,8 @@ describe('runCommand REST-first path', () => { }); vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + mockFetch.mockResolvedValueOnce(makeConnectedServerResponse()); + mockFetch.mockResolvedValueOnce(makeTextResponse(404, 'Not Found')); const { runCommand } = await import('./run.js'); await runCommand({ @@ -360,14 +368,21 @@ describe('runCommand REST-first path', () => { 'cli-session-cache-path': join(cacheDir, '.cli-session.{pid}'), } as never); - vi.clearAllMocks(); + const inspectCalls = mockFetch.mock.calls.filter( + ([url]) => String(url).includes('/api/v1/inspect'), + ); + expect(inspectCalls).toHaveLength(2); + expect(String(mockFetch.mock.calls[0][0])).toContain('target=runner'); - // fetch should not have been called for /api/tool-invocations + // The cached compatibility hint does not suppress status checking. A + // missing invocation endpoint still falls back to MCP normally. const toolInvocationCalls = mockFetch.mock.calls.filter( - ([url]) => typeof url === 'string' && url.includes('tool-invocations'), + ([url]) => String(url).includes('tool-invocations'), ); - expect(toolInvocationCalls).toHaveLength(0); + expect(toolInvocationCalls).toHaveLength(1); // MCP was used expect(transportState.instances.length).toBeGreaterThan(0); + + vi.clearAllMocks(); }); }); diff --git a/src/commands/run/run.rest-fallback.test.ts b/src/commands/run/run.rest-fallback.test.ts index 54c49429..01ca17a4 100644 --- a/src/commands/run/run.rest-fallback.test.ts +++ b/src/commands/run/run.rest-fallback.test.ts @@ -225,6 +225,10 @@ describe('runCommand REST-first path', () => { }; } + function makeConnectedServerResponse() { + return makeRestResponse(200, { kind: 'server', server: 'runner', status: 'connected', available: true }); + } + it('falls back to MCP for endpoint 404 variants and persists hasRestEndpoint=false', async () => { await writeCliSessionCache(cachePath, { sessionId: 'cached-session', @@ -266,6 +270,7 @@ describe('runCommand REST-first path', () => { hasRestEndpoint: true, }); + mockFetch.mockResolvedValueOnce(makeConnectedServerResponse()); mockFetch.mockResolvedValueOnce(makeTextResponse(404, 'Not Found')); // schema GET mockFetch.mockResolvedValueOnce(makeRestResponse(502, { error: 'upstream failed' })); diff --git a/src/commands/run/run.rest-primary.test.ts b/src/commands/run/run.rest-primary.test.ts index d68cca35..e71dad8c 100644 --- a/src/commands/run/run.rest-primary.test.ts +++ b/src/commands/run/run.rest-primary.test.ts @@ -225,6 +225,10 @@ describe('runCommand REST-first path', () => { }; } + function makeConnectedServerResponse() { + return makeRestResponse(200, { kind: 'server', server: 'runner', status: 'connected', available: true }); + } + it('uses REST when hasRestEndpoint is true in cache and skips MCP', async () => { await writeCliSessionCache(cachePath, { sessionId: 'cached-session', @@ -239,6 +243,7 @@ describe('runCommand REST-first path', () => { server: 'runner', tool: 'echo_args', }; + mockFetch.mockResolvedValueOnce(makeConnectedServerResponse()); mockFetch.mockResolvedValueOnce(makeTextResponse(404, 'Not Found')); // schema GET mockFetch.mockResolvedValueOnce(makeRestResponse(200, restResult)); @@ -257,7 +262,7 @@ describe('runCommand REST-first path', () => { 'cli-session-cache-path': join(cacheDir, '.cli-session.{pid}'), } as never); - const [, requestInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + const [, requestInit] = mockFetch.mock.calls[2] as [string, RequestInit]; expect((requestInit.headers as Record)['mcp-session-id']).toBe('cached-session'); expect(JSON.parse(String(requestInit.body))).toMatchObject({ tool: 'runner/echo_args', @@ -290,6 +295,7 @@ describe('runCommand REST-first path', () => { server: 'runner', tool: 'echo_args', }; + mockFetch.mockResolvedValueOnce(makeConnectedServerResponse()); mockFetch.mockResolvedValueOnce(makeTextResponse(404, 'Not Found')); mockFetch.mockResolvedValueOnce(makeRestResponse(200, restResult)); @@ -303,7 +309,7 @@ describe('runCommand REST-first path', () => { 'cli-session-cache-path': join(cacheDir, '.cli-session.{pid}'), } as never); - const [, requestInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + const [, requestInit] = mockFetch.mock.calls[2] as [string, RequestInit]; const headers = requestInit.headers as Record; const body = JSON.parse(String(requestInit.body)) as { _meta: { context: { sessionId: string } } }; @@ -319,6 +325,7 @@ describe('runCommand REST-first path', () => { server: 'runner', tool: 'echo_args', }; + mockFetch.mockResolvedValueOnce(makeConnectedServerResponse()); mockFetch.mockResolvedValueOnce(makeTextResponse(404, 'Not Found')); mockFetch.mockResolvedValueOnce(makeRestResponse(200, restResult, { sessionId: 'rest-session-123' })); @@ -349,6 +356,7 @@ describe('runCommand REST-first path', () => { server: 'runner', tool: 'echo_args', }; + mockFetch.mockResolvedValueOnce(makeConnectedServerResponse()); mockFetch.mockResolvedValueOnce(makeTextResponse(404, 'Not Found')); mockFetch.mockResolvedValueOnce(makeRestResponse(200, restResult)); @@ -374,7 +382,32 @@ describe('runCommand REST-first path', () => { expect(cache?.hasRestEndpoint).toBe(true); }); + it('returns server_loading before attempting a REST or MCP tool invocation', async () => { + mockFetch.mockResolvedValueOnce( + makeRestResponse(200, { kind: 'server', server: 'runner', status: 'loading', available: false }), + ); + const stderr: string[] = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + + const { runCommand } = await import('./run.js'); + await runCommand({ + tool: 'runner/echo_args', + args: '{"message":"hi"}', + 'config-dir': cacheDir, + 'cli-session-cache-path': join(cacheDir, '.cli-session.{pid}'), + } as never); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(transportState.instances).toHaveLength(0); + expect(stderr.join('')).toContain('server_loading'); + expect(stderr.join('')).toContain('1mcp wait runner'); + }); + it('initializes a fresh MCP transport while preserving the logical context session id', async () => { + mockFetch.mockResolvedValueOnce(makeConnectedServerResponse()); mockFetch.mockResolvedValueOnce(makeTextResponse(404, 'Not Found')); mockFetch.mockResolvedValueOnce(makeTextResponse(404, 'Cannot POST /api/v1/tool-invocations')); diff --git a/src/commands/run/run.ts b/src/commands/run/run.ts index 9da4071d..2c0711a3 100644 --- a/src/commands/run/run.ts +++ b/src/commands/run/run.ts @@ -54,6 +54,15 @@ interface ApiInspectToolResult { outputSchema?: Record; } +interface ApiInspectServerResult { + kind: 'server'; + server: string; + status: string; + available: boolean; + authorizationUrl?: string; + error?: string; +} + export { buildServerUrl, deleteCliSessionCache, @@ -77,6 +86,7 @@ export async function runCommand(options: RunCommandOptions): Promise { clientSurface: 'run', version: 'run', options, + alwaysTryRest: true, rest: async (context) => tryRunRest(context, options, toolReference, await stdinPromise), mcp: async (context) => { const stdinText = await stdinPromise; @@ -145,6 +155,13 @@ async function tryRunRest( ? parseJsonObject(stdinText) : {}; + // Parse explicit input before any network activity so invalid command input + // remains a local validation error even while a backend is starting. + const statusResponse = await checkServerStatus(apiClient, toolReference.serverName); + if (statusResponse) { + return statusResponse; + } + const toolInfo = (needsSchemaForStdin && restArgs === null) || needsSchemaForValidation ? await fetchToolInfoFromApi(apiClient, toolReference, options.tool) @@ -240,6 +257,87 @@ async function tryRunRest( }; } +async function checkServerStatus( + apiClient: ApiClient, + serverName: string, +): Promise | undefined> { + const apiResponse = await apiClient.get(API_INSPECT_ENDPOINT, { target: serverName }); + + if (apiResponse.ok && apiResponse.data?.kind === 'server') { + const server = apiResponse.data; + if (server.status === 'connected' && server.available) { + return undefined; + } + + if (server.status === 'pending' || server.status === 'loading') { + return statusErrorResponse( + 'server_loading', + `Server '${serverName}' is ${server.status}. Wait for it to become connected.`, + `1mcp wait ${serverName}`, + server, + ); + } + + if (server.status === 'awaiting_oauth') { + const guidance = server.authorizationUrl + ? `Complete OAuth authorization at ${server.authorizationUrl}.` + : `Complete OAuth authorization for '${serverName}'.`; + return statusErrorResponse('server_awaiting_oauth', guidance, `1mcp inspect ${serverName}`, server); + } + + return statusErrorResponse( + 'server_unavailable', + `Server '${serverName}' is ${server.status}${server.error ? `: ${server.error}` : ''}.`, + `1mcp mcp restart ${serverName}`, + server, + ); + } + + if (apiResponse.status === 401 || apiResponse.status === 403) { + return { + status: 'auth_required', + message: 'Authentication required before checking server status.', + }; + } + + // A text-only 404 identifies pre-status runtimes. Retain the established + // REST-to-MCP compatibility fallback only when no status endpoint exists. + if (isEndpointNotFoundResponse(apiResponse.status, apiResponse.error)) { + return { status: 'fallback', reason: 'endpoint_missing' }; + } + + return { + status: 'error', + message: apiResponse.error ?? `Unable to check status for server '${serverName}'.`, + }; +} + +function statusErrorResponse( + code: 'server_loading' | 'server_unavailable' | 'server_awaiting_oauth', + message: string, + recoveryCommand: string, + details: ApiInspectServerResult, +): ClientSurfaceRestResponse { + const formattedMessage = `${code}: ${message} Recovery: ${recoveryCommand}`; + return { + status: 'success', + value: { + response: { + rawResponse: { + jsonrpc: '2.0', + id: 0, + error: { + code: -32010, + message: formattedMessage, + data: { code, recoveryCommand, details }, + }, + }, + retryWithFreshSession: false, + }, + }, + }; +} + function isEndpointNotFoundResponse(status: number, error?: string): boolean { return status === 404 && /^HTTP 404\b/u.test(error ?? ''); } diff --git a/src/commands/shared/clientSurfaceAttachment.test.ts b/src/commands/shared/clientSurfaceAttachment.test.ts index a36d3cc1..1389b3c3 100644 --- a/src/commands/shared/clientSurfaceAttachment.test.ts +++ b/src/commands/shared/clientSurfaceAttachment.test.ts @@ -191,6 +191,34 @@ describe('attachReusableClientSurface', () => { ); }); + it('can require a fresh REST status check despite a negative cache hint', async () => { + const ports = makePorts({ + cachedSession: { + sessionId: 'cached-session', + serverUrl: 'http://127.0.0.1:3050/mcp', + contextHash: 'hash-from-port', + savedAt: 1000, + hasRestEndpoint: false, + }, + }); + const rest = vi.fn(async () => ({ status: 'success' as const, value: 'rest-value' })); + const mcp = unusedAdapter(); + + const result = await attachReusableClientSurface({ + clientSurface: 'wait', + version: 'wait', + options: {}, + alwaysTryRest: true, + ports, + rest, + mcp, + }); + + expect(result).toMatchObject({ status: 'success', protocol: 'rest', value: 'rest-value' }); + expect(rest).toHaveBeenCalled(); + expect(mcp).not.toHaveBeenCalled(); + }); + it('falls back to MCP and persists unsupported REST on endpoint-missing responses', async () => { const ports = makePorts({ cachedSession: { diff --git a/src/commands/shared/clientSurfaceAttachment.ts b/src/commands/shared/clientSurfaceAttachment.ts index aba65861..ab1aa99a 100644 --- a/src/commands/shared/clientSurfaceAttachment.ts +++ b/src/commands/shared/clientSurfaceAttachment.ts @@ -20,7 +20,7 @@ import type { ContextData } from '@src/types/context.js'; import { resolveCanonicalSessionId, withCanonicalSessionId } from '@src/utils/context/sessionIdentity.js'; import { stripMcpSuffix } from '@src/utils/urlUtils.js'; -export type ReusableClientSurface = 'run' | 'inspect' | 'instructions'; +export type ReusableClientSurface = 'run' | 'inspect' | 'instructions' | 'wait'; export type FreshClientSurface = 'stdio-proxy'; export type RestFallbackReason = 'endpoint_missing' | 'transient_failure' | 'mcp_required'; @@ -167,6 +167,7 @@ export interface AttachReusableClientSurfaceOptions>; rest: (context: ClientSurfaceAttachmentContext) => Promise>; mcp: ( @@ -268,7 +269,7 @@ export async function attachReusableClientSurface + commandYargs + .options(globalOptions || {}) + .positional('server', { describe: 'Configured static server to wait for', type: 'string' }) + .option('url', { alias: 'u', describe: 'Override auto-detected 1MCP server URL', type: 'string' }) + .option('context', { describe: 'Use a named Runtime Target Context', type: 'string' }) + .option('preset', { alias: 'p', describe: 'Filter the running server with a preset', type: 'string' }) + .option('tag-filter', { alias: 'f', describe: 'Apply an advanced tag filter expression', type: 'string' }) + .option('tags', { describe: 'Apply simple comma-separated tags', type: 'array', string: true }) + .option('timeout', { describe: 'Maximum wait time in milliseconds', type: 'number', default: 30_000 }) + .option('format', { describe: 'Output format', type: 'string', choices: ['toon', 'text', 'json'] }) + .example('$0 wait', 'Wait for all matching configured static servers') + .example('$0 wait filesystem --timeout 60000', 'Wait for one configured static server'), + async (argv) => { + const { waitCommand } = await import('./wait.js'); + await runCliCommand(argv as Parameters[0], waitCommand); + }, + ); +} diff --git a/src/commands/wait/wait.test.ts b/src/commands/wait/wait.test.ts new file mode 100644 index 00000000..8a382854 --- /dev/null +++ b/src/commands/wait/wait.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { ClientSurfaceAttachmentContext } from '@src/commands/shared/clientSurfaceAttachment.js'; + +import { selectWaitServers, waitForServers, WaitCommandError, type WaitCommandOptions } from './wait.js'; + +const baseContext: ClientSurfaceAttachmentContext = { + target: { + cwd: '/tmp/project', + projectRoot: '/tmp/project', + projectConfig: null, + mergedOptions: {}, + discoveredUrl: 'http://127.0.0.1:3050/mcp', + serverUrl: new URL('http://127.0.0.1:3050/mcp'), + source: 'user', + }, + options: {}, + baseUrl: 'http://127.0.0.1:3050', + serverUrl: new URL('http://127.0.0.1:3050/mcp'), + context: { + project: { path: '/tmp/project', cwd: '/tmp/project', name: 'project' }, + user: {}, + environment: {}, + }, + contextHash: 'wait-test', + cachePath: '/tmp/wait-test', + cachedSession: null, + requestSessionId: 'wait-session', + sessionId: 'wait-session', +}; + +function jsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (name: string) => (name === 'content-type' ? 'application/json' : null) }, + json: async () => body, + }; +} + +describe('wait status workflow', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('waits only for enabled configured static servers and succeeds when connected', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + kind: 'servers', + servers: [ + { server: 'static', type: 'external', status: 'connected', available: true, loadTracked: true }, + { server: 'template', type: 'template', status: 'connected', available: true, loadTracked: false }, + ], + }), + ), + ); + + const result = await waitForServers(baseContext, 1_000); + + expect(result).toMatchObject({ + status: 'success', + value: { servers: [{ server: 'static', status: 'connected' }] }, + }); + }); + + it('rejects a template target without waiting', () => { + try { + selectWaitServers( + [{ server: 'template', type: 'template', status: 'connected', available: true, loadTracked: false }], + 'template', + ); + throw new Error('Expected template target to be rejected'); + } catch (error) { + expect(error).toBeInstanceOf(WaitCommandError); + expect(error).toMatchObject({ code: 'server_not_load_tracked' }); + } + }); + + it('stops immediately for terminal startup state', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + kind: 'servers', + servers: [{ server: 'static', type: 'external', status: 'failed', available: false, loadTracked: true }], + }), + ), + ); + + await expect(waitForServers(baseContext, 1_000)).rejects.toMatchObject({ + code: 'server_unavailable', + recoveryCommand: '1mcp mcp restart static', + }); + }); +}); diff --git a/src/commands/wait/wait.ts b/src/commands/wait/wait.ts new file mode 100644 index 00000000..4056afcf --- /dev/null +++ b/src/commands/wait/wait.ts @@ -0,0 +1,199 @@ +import { encode } from '@toon-format/toon'; + +import { ApiClient } from '@src/commands/shared/apiClient.js'; +import { + attachReusableClientSurface, + type ClientSurfaceAttachmentContext, + formatClientSurfaceAuthRequiredMessage, +} from '@src/commands/shared/clientSurfaceAttachment.js'; +import { API_INSPECT_ENDPOINT } from '@src/constants/api.js'; +import type { GlobalOptions } from '@src/globalOptions.js'; + +export interface WaitCommandOptions extends GlobalOptions { + server?: string; + url?: string; + context?: string; + preset?: string; + filter?: string; + tags?: string[]; + 'tag-filter'?: string; + timeout?: number; + format?: 'toon' | 'text' | 'json'; +} + +interface InspectServerSummary { + server: string; + type: string; + status: string; + available: boolean; + loadTracked: boolean; +} + +interface InspectServersResult { + kind: 'servers'; + servers: InspectServerSummary[]; +} + +interface WaitResult { + kind: 'wait'; + servers: InspectServerSummary[]; + waitedMs: number; +} + +export class WaitCommandError extends Error { + constructor( + public readonly code: string, + message: string, + public readonly recoveryCommand: string, + public readonly details?: unknown, + ) { + super(message); + this.name = 'WaitCommandError'; + } +} + +export async function waitCommand(options: WaitCommandOptions): Promise { + const timeout = options.timeout ?? 30_000; + if (!Number.isFinite(timeout) || timeout <= 0) { + throw new WaitCommandError( + 'validation_timeout', + '--timeout must be a positive number of milliseconds.', + '1mcp wait', + ); + } + + const attachment = await attachReusableClientSurface({ + clientSurface: 'wait', + version: 'wait', + options, + alwaysTryRest: true, + rest: (context) => waitForServers(context, timeout), + // `wait` deliberately has no MCP fallback: /api/v1/inspect is the + // authenticated client-facing status contract and MCP cannot report the + // loading states needed to decide whether it is safe to invoke a tool. + mcp: async () => ({ status: 'error', message: 'The running 1MCP server does not support /api/v1/inspect.' }), + }); + + if (attachment.status !== 'success') { + throw new WaitCommandError('server_status_unavailable', attachment.message, '1mcp inspect'); + } + + const output = formatWaitOutput(attachment.value, options.format ?? 'toon'); + if (output.length > 0) { + process.stdout.write(`${output}\n`); + } +} + +export async function waitForServers( + context: ClientSurfaceAttachmentContext, + timeout: number, +): Promise< + | { status: 'success'; value: WaitResult } + | { status: 'auth_required'; message: string } + | { status: 'error'; message: string } +> { + const apiClient = new ApiClient({ + baseUrl: context.baseUrl, + bearerToken: context.bearerToken, + sessionId: context.sessionId, + context: context.context, + }); + const startedAt = Date.now(); + let lastServers: InspectServerSummary[] = []; + + while (true) { + const response = await apiClient.get(API_INSPECT_ENDPOINT, buildWaitQuery(context.options)); + if (response.status === 401 || response.status === 403) { + return { status: 'auth_required', message: formatClientSurfaceAuthRequiredMessage(context) }; + } + if (!response.ok || response.data?.kind !== 'servers') { + return { status: 'error', message: response.error ?? 'Invalid response from /api/v1/inspect.' }; + } + + const selected = selectWaitServers(response.data.servers, context.options.server); + lastServers = selected; + throwForTerminalState(selected, context.options.server); + + if (selected.every((server) => server.status === 'connected' && server.available)) { + return { + status: 'success', + value: { kind: 'wait', servers: selected, waitedMs: Date.now() - startedAt }, + }; + } + + if (Date.now() - startedAt >= timeout) { + const target = context.options.server ?? 'all configured servers'; + throw new WaitCommandError( + 'server_wait_timeout', + `Timed out after ${timeout}ms waiting for ${target}.`, + context.options.server ? `1mcp wait ${context.options.server}` : '1mcp wait', + { servers: lastServers }, + ); + } + + await delay(Math.min(250, Math.max(timeout - (Date.now() - startedAt), 1))); + } +} + +function buildWaitQuery(options: WaitCommandOptions): Record { + const query: Record = {}; + if (options.preset) query.preset = options.preset; + else if (options['tag-filter']) query['tag-filter'] = options['tag-filter']; + else if (options.filter) query.filter = options.filter; + else if (options.tags?.length) query.tags = options.tags.join(','); + return query; +} + +export function selectWaitServers(servers: InspectServerSummary[], target?: string): InspectServerSummary[] { + if (target) { + const server = servers.find((candidate) => candidate.server === target); + if (!server) { + throw new WaitCommandError('server_not_found', `Server '${target}' was not found.`, `1mcp inspect ${target}`); + } + if (!server.loadTracked) { + throw new WaitCommandError( + 'server_not_load_tracked', + `Server '${target}' is not a tracked static startup server.`, + `1mcp inspect ${target}`, + { server }, + ); + } + return [server]; + } + + return servers.filter((server) => server.loadTracked); +} + +function throwForTerminalState(servers: InspectServerSummary[], requestedServer?: string): void { + const terminal = servers.find((server) => + ['failed', 'cancelled', 'awaiting_oauth', 'disconnected', 'error', 'unknown'].includes(server.status), + ); + if (!terminal) return; + + if (terminal.status === 'awaiting_oauth') { + throw new WaitCommandError( + 'server_awaiting_oauth', + `Server '${terminal.server}' requires OAuth authorization.`, + `1mcp inspect ${terminal.server}`, + { server: terminal }, + ); + } + + throw new WaitCommandError( + 'server_unavailable', + `Server '${terminal.server}' is ${terminal.status}.`, + `1mcp mcp restart ${requestedServer ?? terminal.server}`, + { server: terminal }, + ); +} + +function formatWaitOutput(result: WaitResult, format: NonNullable): string { + if (format === 'json') return JSON.stringify(result, null, 2); + if (format === 'toon') return encode(result); + if (result.servers.length === 0) return 'No matching tracked static servers.'; + return result.servers.map((server) => `${server.server}: ${server.status}`).join('\n'); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/src/index.ts b/src/index.ts index 6d27a069..7c7e9c79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { setupPresetCommands } from './commands/preset/index.js'; import { setupProxyCommand } from './commands/proxy/index.js'; import { setupRegistryCommands } from './commands/registry/index.js'; import { setupRunCommand } from './commands/run/index.js'; +import { setupWaitCommand } from './commands/wait/index.js'; import { serverOptions, setupServeCommand } from './commands/serve/index.js'; import { setupTargetCommands } from './commands/target/index.js'; import { configureGlobalLogger } from './logger/configureGlobalLogger.js'; @@ -66,6 +67,7 @@ yargsInstance = setupServeCommand(yargsInstance); yargsInstance = setupProxyCommand(yargsInstance); yargsInstance = setupInspectCommand(yargsInstance); yargsInstance = setupRunCommand(yargsInstance); +yargsInstance = setupWaitCommand(yargsInstance); yargsInstance = setupRegistryCommands(yargsInstance); yargsInstance = setupTargetCommands(yargsInstance); @@ -92,6 +94,7 @@ function checkGlobalOptionConflicts(argv: string[]): void { arg === 'proxy' || arg === 'inspect' || arg === 'run' || + arg === 'wait' || arg === 'target', ); diff --git a/src/transport/http/routes/apiRoutes.test.ts b/src/transport/http/routes/apiRoutes.test.ts index 7a2f92ce..022c6d56 100644 --- a/src/transport/http/routes/apiRoutes.test.ts +++ b/src/transport/http/routes/apiRoutes.test.ts @@ -490,4 +490,34 @@ describe('apiRoutes inspect', () => { }, }); }); + + it('returns an inspectable empty result for an unavailable configured static server', async () => { + mockedLoadDeclaredServerConfigs.mockReturnValue({ + staticServers: { + slow: { type: 'stdio', command: 'node', args: ['slow-server.js'], tags: ['slow'] }, + }, + templateServers: {}, + errors: [], + }); + const serverManager = { + getClients: vi.fn(() => new Map()), + getInstructionAggregator: vi.fn(() => undefined), + getLazyLoadingOrchestrator: vi.fn(() => undefined), + getServerRegistry: vi.fn(() => ({ getServerNames: vi.fn(() => []), get: vi.fn(() => undefined) })), + }; + const handler = createInspectHandler(serverManager as never); + const res = createMockResponse(); + + await invokeInspectRoute(scopeAuthMiddleware, { query: { target: 'slow' } }, res); + await invokeInspectRoute(handler, { query: { target: 'slow' } }, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + kind: 'server', + server: 'slow', + available: false, + loadTracked: true, + tools: [], + }); + }); }); diff --git a/src/transport/http/routes/inspectHelpers.test.ts b/src/transport/http/routes/inspectHelpers.test.ts new file mode 100644 index 00000000..1ab64f61 --- /dev/null +++ b/src/transport/http/routes/inspectHelpers.test.ts @@ -0,0 +1,30 @@ +import { LoadingState } from '@src/core/loading/loadingStateTracker.js'; +import { ClientStatus } from '@src/core/types/client.js'; + +import { describe, expect, it } from 'vitest'; + +import { deriveServerState } from './inspectHelpers.js'; + +describe('deriveServerState', () => { + it('keeps a tracked loading state authoritative over a stale connected client', () => { + expect( + deriveServerState( + 'connected', + true, + { status: ClientStatus.Connected } as never, + { name: 'slow', state: LoadingState.Loading, retryCount: 0 }, + ), + ).toEqual({ status: 'loading', available: false }); + }); + + it('uses the connected client once the loading tracker is ready', () => { + expect( + deriveServerState( + undefined, + undefined, + { status: ClientStatus.Connected } as never, + { name: 'ready', state: LoadingState.Ready, retryCount: 0 }, + ), + ).toEqual({ status: 'connected', available: true }); + }); +}); diff --git a/src/transport/http/routes/inspectHelpers.ts b/src/transport/http/routes/inspectHelpers.ts index 578dd694..8c906375 100644 --- a/src/transport/http/routes/inspectHelpers.ts +++ b/src/transport/http/routes/inspectHelpers.ts @@ -1,6 +1,7 @@ import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import { MCP_URI_SEPARATOR } from '@src/constants.js'; +import { LoadingState, type ServerLoadingInfo } from '@src/core/loading/loadingStateTracker.js'; import { FilteringService } from '@src/core/filtering/filteringService.js'; import { ClientStatus, type OutboundConnection } from '@src/core/types/client.js'; import { InboundConnectionConfig } from '@src/core/types/index.js'; @@ -25,6 +26,8 @@ export interface ServerSummary { type: string; status: string; available: boolean; + /** True only for enabled, configured static servers tracked during startup. */ + loadTracked: boolean; toolCount: number; hasInstructions: boolean; } @@ -41,7 +44,10 @@ export interface InspectServerPayload { type: string; status: string; available: boolean; + loadTracked: boolean; instructions: string | null; + authorizationUrl?: string; + error?: string; tools: Array<{ tool: string; qualifiedName: string; @@ -185,7 +191,15 @@ export function deriveServerState( adapterStatus: string | undefined, adapterAvailable: boolean | undefined, connection?: OutboundConnection, + loadingInfo?: ServerLoadingInfo, ): { status: string; available: boolean } { + // During async startup, the loading tracker is the authoritative source until + // a server has reached Ready. A stale connection must not make a loading or + // terminal backend look callable. + if (loadingInfo && loadingInfo.state !== LoadingState.Ready) { + return { status: loadingInfo.state, available: false }; + } + if (connection) { switch (connection.status) { case ClientStatus.Connected: diff --git a/src/transport/http/routes/inspectRoutes.ts b/src/transport/http/routes/inspectRoutes.ts index 2ab14fe9..d9f21146 100644 --- a/src/transport/http/routes/inspectRoutes.ts +++ b/src/transport/http/routes/inspectRoutes.ts @@ -5,6 +5,8 @@ import { McpConfigManager } from '@src/config/mcpConfigManager.js'; import { CapabilityAggregator } from '@src/core/capabilities/capabilityAggregator.js'; import { ToolRegistry } from '@src/core/capabilities/toolRegistry.js'; import { FilteringService } from '@src/core/filtering/filteringService.js'; +import { LoadingState, type ServerLoadingInfo } from '@src/core/loading/loadingStateTracker.js'; +import { McpLoadingManager } from '@src/core/loading/mcpLoadingManager.js'; import { ServerRegistry } from '@src/core/server/adapters/ServerRegistry.js'; import { filterDisabledTools, @@ -83,6 +85,20 @@ function getServerTargetConfigs(declaredServers: DeclaredServers): ServerConfigM }; } +function getLoadingInfo(serverName: string): ServerLoadingInfo | undefined { + try { + return McpLoadingManager.current.getStateTracker().getServerState(serverName); + } catch { + // Inspect is also used by lightweight unit and compatibility runtimes that + // have no loading manager. Their connection/adapter state remains valid. + return undefined; + } +} + +function isLoadTrackedStaticServer(declaredServers: DeclaredServers, serverName: string): boolean { + return Boolean(declaredServers.staticServers[serverName] && !declaredServers.staticServers[serverName].disabled); +} + function hasDisabledTools(serverConfigs: ServerConfigMap, serverName: string): boolean { return getDisabledToolsForServer(serverConfigs, serverName).length > 0; } @@ -217,7 +233,8 @@ async function buildServerSummaries( for (const [cleanName, info] of serverMap) { const adapter = serverRegistry.get(cleanName); const connection = resolveConnectionByServerName(summaryConnections, cleanName); - const state = deriveServerState(adapter?.getStatus(), adapter?.isAvailable(), connection); + const loadingInfo = getLoadingInfo(cleanName); + const state = deriveServerState(adapter?.getStatus(), adapter?.isAvailable(), connection, loadingInfo); const type = adapter?.type ?? (declaredServers.templateServers[cleanName] ? 'template' : 'external'); servers.push({ @@ -225,6 +242,7 @@ async function buildServerSummaries( type: String(type), status: state.status, available: state.available, + loadTracked: isLoadTrackedStaticServer(declaredServers, cleanName), toolCount: info.toolCount, hasInstructions: info.hasInstructions, }); @@ -397,24 +415,49 @@ export function createInspectHandler(serverManager: ServerManager): RequestHandl const connection = sessionConnection ?? resolveConnectionByServerName(filteredConnections, serverName); const declaredTemplateConfig = declaredServers.templateServers[serverName]; const declaredStaticConfig = declaredServers.staticServers[serverName]; + const loadingInfo = getLoadingInfo(serverName); if (!adapter && !connection && !declaredTemplateConfig && !declaredStaticConfig) { res.status(404).json({ error: `Server not found: ${serverName}` }); return; } - if (!connection) { - res.status(503).json({ error: `Server '${serverName}' is not currently connected` }); - return; - } - const state = deriveServerState( adapter?.getStatus(requestSessionId ? { sessionId: requestSessionId } : undefined), adapter?.isAvailable(requestSessionId ? { sessionId: requestSessionId } : undefined), connection, + loadingInfo, ); const type = adapter?.type ?? (declaredTemplateConfig ? 'template' : 'external'); const instructions = instructionAggregator?.getServerInstructions(serverName) ?? null; + const loadTracked = isLoadTrackedStaticServer(declaredServers, serverName); + + // Static startup targets remain inspectable before the first atomic + // capability snapshot. Do not force a direct tools/list call while the + // loading tracker says the backend is not ready. + if (declaredStaticConfig && (!connection || loadingInfo?.state !== LoadingState.Ready)) { + const payload: InspectServerPayload = { + kind: 'server', + server: serverName, + type: String(type), + status: state.status, + available: state.available, + loadTracked, + instructions, + ...(loadingInfo?.authorizationUrl ? { authorizationUrl: loadingInfo.authorizationUrl } : {}), + ...(loadingInfo?.error ? { error: loadingInfo.error.message } : {}), + tools: [], + totalTools: 0, + hasMore: false, + }; + res.json(payload); + return; + } + + if (!connection) { + res.status(503).json({ error: `Server '${serverName}' is not currently connected` }); + return; + } let toolsResult: { tools: ToolSummary[]; totalTools: number; hasMore: boolean; nextCursor?: string }; @@ -458,7 +501,10 @@ export function createInspectHandler(serverManager: ServerManager): RequestHandl type: String(type), status: state.status, available: state.available, + loadTracked, instructions, + ...(loadingInfo?.authorizationUrl ? { authorizationUrl: loadingInfo.authorizationUrl } : {}), + ...(loadingInfo?.error ? { error: loadingInfo.error.message } : {}), tools: toolsResult.tools, totalTools: toolsResult.totalTools, hasMore: toolsResult.hasMore, From 780bbe30be86ec8994b84c2488e5c1f9c0877153 Mon Sep 17 00:00:00 2001 From: Xu Zhipei Date: Wed, 29 Jul 2026 22:55:12 +0800 Subject: [PATCH 2/4] fix(cli): harden async status workflow --- README.md | 4 +- docs/en/guide/advanced/fast-startup.md | 2 + docs/zh/guide/advanced/fast-startup.md | 2 + src/commands/inspect/inspect.ts | 7 +- src/commands/run/run.rest-primary.test.ts | 29 ++++++++ src/commands/run/run.ts | 62 ++++++++-------- src/commands/shared/apiClient.ts | 8 ++- src/commands/shared/commandRunner.test.ts | 20 ++++++ src/commands/shared/commandRunner.ts | 6 +- src/commands/shared/filterSelectionQuery.ts | 14 ++++ src/commands/shared/inspectApiSchemas.ts | 48 +++++++++++++ src/commands/wait/wait.test.ts | 78 +++++++++++++++++++++ src/commands/wait/wait.ts | 67 +++++++++--------- src/transport/http/routes/apiRoutes.test.ts | 24 +++++++ src/transport/http/routes/inspectHelpers.ts | 22 ++++-- src/transport/http/routes/inspectRoutes.ts | 2 +- src/types/serverStatus.ts | 14 ++++ 17 files changed, 328 insertions(+), 81 deletions(-) create mode 100644 src/commands/shared/filterSelectionQuery.ts create mode 100644 src/commands/shared/inspectApiSchemas.ts create mode 100644 src/types/serverStatus.ts diff --git a/README.md b/README.md index f01e7687..c098cace 100644 --- a/README.md +++ b/README.md @@ -142,14 +142,14 @@ flowchart LR H --> B ``` -1MCP runs as an aggregated runtime behind `1mcp serve`. Static servers are prepared from startup configuration, template servers are materialized when client context is known, and the runtime can use async loading and lazy loading to reduce startup blocking and tool-surface noise. Instruction aggregation, presets, and notifications sit alongside that runtime rather than outside it. +1MCP runs as an aggregated runtime behind `1mcp serve`. Static servers are prepared from startup configuration, template servers are materialized when client context is known, and the runtime can use async loading for early HTTP listener availability and lazy loading for a stable tool surface. Instruction aggregation, presets, and notifications sit alongside that runtime rather than outside it. ## Core Capabilities - Unified runtime for many MCP servers behind one `serve` process - CLI mode for progressive discovery with `1mcp instructions`, `1mcp inspect `, `1mcp inspect /`, and `1mcp run / --args ''` - Template servers for per-client or per-session resolution -- Async loading and lazy loading for faster startup and narrower exposure +- Opt-in async loading for early HTTP listener availability when clients can reconcile capability changes - Opt-in automatic recovery for owned stdio backends, with health/status visibility and operator restart controls - Instruction aggregation across static and template-backed servers - Presets, filters, and preset change notifications diff --git a/docs/en/guide/advanced/fast-startup.md b/docs/en/guide/advanced/fast-startup.md index 85fad952..3d91d4c7 100644 --- a/docs/en/guide/advanced/fast-startup.md +++ b/docs/en/guide/advanced/fast-startup.md @@ -81,3 +81,5 @@ Use the inspect API or CLI for an authenticated caller deciding whether to invok ## Configuration Notes Async loading remains opt-in. This page describes the runtime contract rather than deprecated-option cleanup; use current command help and the configuration reference for supported options. + +Related work: [#393](https://github.com/1mcp-app/agent/issues/393) tracks obsolete async option cleanup, and [#405](https://github.com/1mcp-app/agent/issues/405) defines the CLI status and wait workflow. diff --git a/docs/zh/guide/advanced/fast-startup.md b/docs/zh/guide/advanced/fast-startup.md index 9d8764e4..0c972d7d 100644 --- a/docs/zh/guide/advanced/fast-startup.md +++ b/docs/zh/guide/advanced/fast-startup.md @@ -81,3 +81,5 @@ curl http://localhost:3050/health/mcp ## 配置说明 异步加载仍为显式启用。本页描述运行时契约,不重复旧选项的清理;请以当前命令帮助和配置参考中的受支持选项为准。 + +相关工作:[#393](https://github.com/1mcp-app/agent/issues/393) 跟踪废弃异步选项的清理,[#405](https://github.com/1mcp-app/agent/issues/405) 定义 CLI 状态与等待工作流。 diff --git a/src/commands/inspect/inspect.ts b/src/commands/inspect/inspect.ts index 789d3900..95262468 100644 --- a/src/commands/inspect/inspect.ts +++ b/src/commands/inspect/inspect.ts @@ -3,6 +3,7 @@ import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import { findToolByQualifiedName } from '@src/commands/run/runUtils.js'; import { ApiClient } from '@src/commands/shared/apiClient.js'; +import { buildFilterSelectionQuery } from '@src/commands/shared/filterSelectionQuery.js'; import { attachReusableClientSurface, type ClientSurfaceAttachmentContext, @@ -67,12 +68,8 @@ function buildInspectQuery( options: Pick, target?: string, ): Record { - const query: Record = {}; + const query: Record = buildFilterSelectionQuery(options); if (target) query.target = target; - if (options.preset) query.preset = options.preset; - else if (options['tag-filter']) query['tag-filter'] = options['tag-filter']; - else if (options.filter) query.filter = options.filter; - else if (options.tags && options.tags.length > 0) query.tags = options.tags.join(','); if (options.all) query.all = 'true'; else if (options.limit && options.limit !== 20) query.limit = String(options.limit); if (options.cursor) query.cursor = options.cursor; diff --git a/src/commands/run/run.rest-primary.test.ts b/src/commands/run/run.rest-primary.test.ts index e71dad8c..4a04e24f 100644 --- a/src/commands/run/run.rest-primary.test.ts +++ b/src/commands/run/run.rest-primary.test.ts @@ -406,6 +406,35 @@ describe('runCommand REST-first path', () => { expect(stderr.join('')).toContain('1mcp wait runner'); }); + it('checks status with the same preset used by the invocation surface', async () => { + mockFetch.mockResolvedValueOnce(makeConnectedServerResponse()); + mockFetch.mockResolvedValueOnce(makeTextResponse(404, 'Not Found')); + mockFetch.mockResolvedValueOnce( + makeRestResponse(200, { + result: { content: [{ type: 'text', text: 'ok' }], isError: false }, + server: 'runner', + tool: 'echo_args', + }), + ); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + const { runCommand } = await import('./run.js'); + await runCommand({ + tool: 'runner/echo_args', + args: '{"message":"hi"}', + preset: 'development', + 'config-dir': cacheDir, + 'cli-session-cache-path': join(cacheDir, '.cli-session.{pid}'), + } as never); + + const statusUrl = new URL(String(mockFetch.mock.calls[0][0])); + const schemaUrl = new URL(String(mockFetch.mock.calls[1][0])); + expect(statusUrl.searchParams.get('target')).toBe('runner'); + expect(statusUrl.searchParams.get('preset')).toBe('development'); + expect(schemaUrl.searchParams.get('target')).toBe('runner/echo_args'); + expect(schemaUrl.searchParams.get('preset')).toBe('development'); + }); + it('initializes a fresh MCP transport while preserving the logical context session id', async () => { mockFetch.mockResolvedValueOnce(makeConnectedServerResponse()); mockFetch.mockResolvedValueOnce(makeTextResponse(404, 'Not Found')); diff --git a/src/commands/run/run.ts b/src/commands/run/run.ts index 2c0711a3..ea8eea2b 100644 --- a/src/commands/run/run.ts +++ b/src/commands/run/run.ts @@ -14,6 +14,12 @@ import { validateToolArgs, } from '@src/commands/run/runUtils.js'; import { ApiClient } from '@src/commands/shared/apiClient.js'; +import { buildFilterSelectionQuery } from '@src/commands/shared/filterSelectionQuery.js'; +import { + type ApiInspectServerResult, + inspectServerResultSchema, + inspectToolResultSchema, +} from '@src/commands/shared/inspectApiSchemas.js'; import { attachReusableClientSurface, type ClientSurfaceAttachmentContext, @@ -44,25 +50,6 @@ export interface RunCommandOptions extends GlobalOptions { tool: string; } -interface ApiInspectToolResult { - kind: 'tool'; - server: string; - tool: string; - qualifiedName: string; - description?: string; - inputSchema: Record; - outputSchema?: Record; -} - -interface ApiInspectServerResult { - kind: 'server'; - server: string; - status: string; - available: boolean; - authorizationUrl?: string; - error?: string; -} - export { buildServerUrl, deleteCliSessionCache, @@ -157,14 +144,14 @@ async function tryRunRest( // Parse explicit input before any network activity so invalid command input // remains a local validation error even while a backend is starting. - const statusResponse = await checkServerStatus(apiClient, toolReference.serverName); + const statusResponse = await checkServerStatus(apiClient, toolReference.serverName, context.options); if (statusResponse) { return statusResponse; } const toolInfo = (needsSchemaForStdin && restArgs === null) || needsSchemaForValidation - ? await fetchToolInfoFromApi(apiClient, toolReference, options.tool) + ? await fetchToolInfoFromApi(apiClient, toolReference, options.tool, context.options) : null; if (restArgs === null && !toolInfo) { @@ -260,11 +247,19 @@ async function tryRunRest( async function checkServerStatus( apiClient: ApiClient, serverName: string, + options: RunCommandOptions, ): Promise | undefined> { - const apiResponse = await apiClient.get(API_INSPECT_ENDPOINT, { target: serverName }); + const apiResponse = await apiClient.get(API_INSPECT_ENDPOINT, { + ...buildFilterSelectionQuery(options), + target: serverName, + }); - if (apiResponse.ok && apiResponse.data?.kind === 'server') { - const server = apiResponse.data; + if (apiResponse.ok) { + const parsed = inspectServerResultSchema.safeParse(apiResponse.data); + if (!parsed.success) { + return { status: 'error', message: `Invalid response from ${API_INSPECT_ENDPOINT}.` }; + } + const server = parsed.data; if (server.status === 'connected' && server.available) { return undefined; } @@ -354,24 +349,31 @@ async function fetchToolInfoFromApi( apiClient: ApiClient, toolReference: ReturnType, displayToolName: string, + options: RunCommandOptions, ): Promise { - const apiResponse = await apiClient.get(API_INSPECT_ENDPOINT, { + const apiResponse = await apiClient.get(API_INSPECT_ENDPOINT, { + ...buildFilterSelectionQuery(options), target: displayToolName, }); - if (!apiResponse.ok || !apiResponse.data || apiResponse.data.kind !== 'tool') { + if (!apiResponse.ok) { if (!apiResponse.ok && apiResponse.status !== 404) { logger.debug('fetchToolInfoFromApi: unexpected response status', { status: apiResponse.status }); } return null; } + const parsed = inspectToolResultSchema.safeParse(apiResponse.data); + if (!parsed.success) { + logger.debug('fetchToolInfoFromApi: invalid inspect response'); + return null; + } return extractInspectToolInfo( { - name: apiResponse.data.qualifiedName, - description: apiResponse.data.description, - inputSchema: apiResponse.data.inputSchema, - outputSchema: apiResponse.data.outputSchema, + name: parsed.data.qualifiedName, + description: parsed.data.description, + inputSchema: parsed.data.inputSchema, + outputSchema: parsed.data.outputSchema, } as Tool, toolReference, ); diff --git a/src/commands/shared/apiClient.ts b/src/commands/shared/apiClient.ts index 62482897..1a95cfd1 100644 --- a/src/commands/shared/apiClient.ts +++ b/src/commands/shared/apiClient.ts @@ -38,7 +38,11 @@ export class ApiClient { this.context = options.context; } - async get(path: string, query?: Record): Promise> { + async get( + path: string, + query?: Record, + options: ApiRequestOptions = {}, + ): Promise> { const url = new URL(`${this.baseUrl}${path}`); if (query) { for (const [key, value] of Object.entries(query)) { @@ -50,7 +54,7 @@ export class ApiClient { if (this.context) { url.searchParams.set('context', encodeContextValue(this.context)); } - return this.request(url.toString(), 'GET'); + return this.request(url.toString(), 'GET', undefined, options); } async post(path: string, body: unknown, options: ApiRequestOptions = {}): Promise> { diff --git a/src/commands/shared/commandRunner.test.ts b/src/commands/shared/commandRunner.test.ts index 8deb5391..b393862d 100644 --- a/src/commands/shared/commandRunner.test.ts +++ b/src/commands/shared/commandRunner.test.ts @@ -61,6 +61,26 @@ describe('runCliCommand', () => { expect(envelope.requestId).toEqual(expect.any(String)); }); + it('writes structured JSON failure envelopes when format json is requested', async () => { + await runCliCommand({ format: 'json' }, async () => { + throw Object.assign(new Error('Timed out waiting for filesystem'), { + code: 'server_wait_timeout', + details: { status: 'timeout', servers: [] }, + recoveryCommand: '1mcp wait filesystem', + }); + }); + + expect(stderr).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + const envelope = JSON.parse(stdout.mock.calls.map((call: unknown[]) => String(call[0])).join('')) as { + error: { code: string; details: { status: string } }; + }; + expect(envelope.error).toMatchObject({ + code: 'server_wait_timeout', + details: { status: 'timeout' }, + }); + }); + it('uses target safety exit code for JSON recovery failures', async () => { await runCliCommand({ json: true }, async () => { throw Object.assign(new Error('Runtime target uses imported insecure TLS metadata and requires confirmation'), { diff --git a/src/commands/shared/commandRunner.ts b/src/commands/shared/commandRunner.ts index edd5896c..119106d8 100644 --- a/src/commands/shared/commandRunner.ts +++ b/src/commands/shared/commandRunner.ts @@ -1,6 +1,6 @@ import type { GlobalOptions } from '@src/globalOptions.js'; -export async function runCliCommand( +export async function runCliCommand( argv: T, fn: (argv: T) => Promise, ): Promise { @@ -21,8 +21,8 @@ export async function runCliCommand { + if (options.preset) return { preset: options.preset }; + if (options['tag-filter']) return { 'tag-filter': options['tag-filter'] }; + if (options.filter) return { filter: options.filter }; + if (options.tags?.length) return { tags: options.tags.join(',') }; + return {}; +} diff --git a/src/commands/shared/inspectApiSchemas.ts b/src/commands/shared/inspectApiSchemas.ts new file mode 100644 index 00000000..60e89473 --- /dev/null +++ b/src/commands/shared/inspectApiSchemas.ts @@ -0,0 +1,48 @@ +import { CLIENT_SERVER_STATUSES } from '@src/types/serverStatus.js'; + +import { z } from 'zod'; + +export const clientServerStatusSchema = z.enum(CLIENT_SERVER_STATUSES); + +export const inspectServerSummarySchema = z + .object({ + server: z.string(), + type: z.string(), + status: clientServerStatusSchema, + available: z.boolean(), + loadTracked: z.boolean(), + }) + .passthrough(); + +export const inspectServersResultSchema = z + .object({ + kind: z.literal('servers'), + servers: z.array(inspectServerSummarySchema), + }) + .passthrough(); + +export const inspectServerResultSchema = z + .object({ + kind: z.literal('server'), + server: z.string(), + status: clientServerStatusSchema, + available: z.boolean(), + authorizationUrl: z.string().optional(), + error: z.string().optional(), + }) + .passthrough(); + +export const inspectToolResultSchema = z + .object({ + kind: z.literal('tool'), + server: z.string(), + tool: z.string(), + qualifiedName: z.string(), + description: z.string().optional(), + inputSchema: z.record(z.string(), z.unknown()), + outputSchema: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough(); + +export type InspectServerSummary = z.infer; +export type ApiInspectServerResult = z.infer; diff --git a/src/commands/wait/wait.test.ts b/src/commands/wait/wait.test.ts index 8a382854..f43cc7da 100644 --- a/src/commands/wait/wait.test.ts +++ b/src/commands/wait/wait.test.ts @@ -92,4 +92,82 @@ describe('wait status workflow', () => { recoveryCommand: '1mcp mcp restart static', }); }); + + it('polls from pending until the selected server is connected', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + kind: 'servers', + servers: [{ server: 'static', type: 'external', status: 'pending', available: false, loadTracked: true }], + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + kind: 'servers', + servers: [{ server: 'static', type: 'external', status: 'connected', available: true, loadTracked: true }], + }), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(waitForServers(baseContext, 1_000)).resolves.toMatchObject({ status: 'success' }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it.each(['awaiting_oauth', 'cancelled'] as const)('stops immediately for %s', async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + kind: 'servers', + servers: [{ server: 'static', type: 'external', status, available: false, loadTracked: true }], + }), + ), + ); + + await expect(waitForServers(baseContext, 1_000)).rejects.toMatchObject({ + code: status === 'awaiting_oauth' ? 'server_awaiting_oauth' : 'server_unavailable', + }); + }); + + it('propagates filter selection to inspect polling', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + kind: 'servers', + servers: [{ server: 'static', type: 'external', status: 'connected', available: true, loadTracked: true }], + }), + ); + vi.stubGlobal('fetch', fetchMock); + + await waitForServers({ ...baseContext, options: { preset: 'development' } }, 1_000); + + expect(String(fetchMock.mock.calls[0][0])).toContain('preset=development'); + }); + + it('rejects malformed inspect payloads', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ kind: 'servers', servers: [{ server: 42 }] }))); + + await expect(waitForServers(baseContext, 1_000)).resolves.toEqual({ + status: 'error', + message: 'Invalid response from /api/v1/inspect.', + }); + }); + + it('aborts an inspect request at the CLI deadline and preserves timeout state', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))); + }), + ), + ); + + const startedAt = Date.now(); + await expect(waitForServers(baseContext, 25)).rejects.toMatchObject({ + code: 'server_wait_timeout', + details: { status: 'timeout', servers: [] }, + }); + expect(Date.now() - startedAt).toBeLessThan(500); + }); }); diff --git a/src/commands/wait/wait.ts b/src/commands/wait/wait.ts index 4056afcf..71e4a1f0 100644 --- a/src/commands/wait/wait.ts +++ b/src/commands/wait/wait.ts @@ -1,6 +1,11 @@ import { encode } from '@toon-format/toon'; import { ApiClient } from '@src/commands/shared/apiClient.js'; +import { buildFilterSelectionQuery } from '@src/commands/shared/filterSelectionQuery.js'; +import { + type InspectServerSummary, + inspectServersResultSchema, +} from '@src/commands/shared/inspectApiSchemas.js'; import { attachReusableClientSurface, type ClientSurfaceAttachmentContext, @@ -21,19 +26,6 @@ export interface WaitCommandOptions extends GlobalOptions { format?: 'toon' | 'text' | 'json'; } -interface InspectServerSummary { - server: string; - type: string; - status: string; - available: boolean; - loadTracked: boolean; -} - -interface InspectServersResult { - kind: 'servers'; - servers: InspectServerSummary[]; -} - interface WaitResult { kind: 'wait'; servers: InspectServerSummary[]; @@ -99,18 +91,34 @@ export async function waitForServers( context: context.context, }); const startedAt = Date.now(); + const deadline = startedAt + timeout; let lastServers: InspectServerSummary[] = []; while (true) { - const response = await apiClient.get(API_INSPECT_ENDPOINT, buildWaitQuery(context.options)); + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throwWaitTimeout(timeout, context.options.server, lastServers); + } + const response = await apiClient.get( + API_INSPECT_ENDPOINT, + buildFilterSelectionQuery(context.options), + { timeout: remaining }, + ); if (response.status === 401 || response.status === 403) { return { status: 'auth_required', message: formatClientSurfaceAuthRequiredMessage(context) }; } - if (!response.ok || response.data?.kind !== 'servers') { + if (Date.now() >= deadline) { + throwWaitTimeout(timeout, context.options.server, lastServers); + } + if (!response.ok) { return { status: 'error', message: response.error ?? 'Invalid response from /api/v1/inspect.' }; } + const parsed = inspectServersResultSchema.safeParse(response.data); + if (!parsed.success) { + return { status: 'error', message: 'Invalid response from /api/v1/inspect.' }; + } - const selected = selectWaitServers(response.data.servers, context.options.server); + const selected = selectWaitServers(parsed.data.servers, context.options.server); lastServers = selected; throwForTerminalState(selected, context.options.server); @@ -121,27 +129,18 @@ export async function waitForServers( }; } - if (Date.now() - startedAt >= timeout) { - const target = context.options.server ?? 'all configured servers'; - throw new WaitCommandError( - 'server_wait_timeout', - `Timed out after ${timeout}ms waiting for ${target}.`, - context.options.server ? `1mcp wait ${context.options.server}` : '1mcp wait', - { servers: lastServers }, - ); - } - - await delay(Math.min(250, Math.max(timeout - (Date.now() - startedAt), 1))); + await delay(Math.min(250, Math.max(deadline - Date.now(), 1))); } } -function buildWaitQuery(options: WaitCommandOptions): Record { - const query: Record = {}; - if (options.preset) query.preset = options.preset; - else if (options['tag-filter']) query['tag-filter'] = options['tag-filter']; - else if (options.filter) query.filter = options.filter; - else if (options.tags?.length) query.tags = options.tags.join(','); - return query; +function throwWaitTimeout(timeout: number, server: string | undefined, servers: InspectServerSummary[]): never { + const target = server ?? 'all configured servers'; + throw new WaitCommandError( + 'server_wait_timeout', + `Timed out after ${timeout}ms waiting for ${target}.`, + server ? `1mcp wait ${server}` : '1mcp wait', + { status: 'timeout', servers }, + ); } export function selectWaitServers(servers: InspectServerSummary[], target?: string): InspectServerSummary[] { diff --git a/src/transport/http/routes/apiRoutes.test.ts b/src/transport/http/routes/apiRoutes.test.ts index 022c6d56..9d90167e 100644 --- a/src/transport/http/routes/apiRoutes.test.ts +++ b/src/transport/http/routes/apiRoutes.test.ts @@ -520,4 +520,28 @@ describe('apiRoutes inspect', () => { tools: [], }); }); + + it('lists tools for a connected configured static server without a loading tracker', async () => { + mockedLoadDeclaredServerConfigs.mockReturnValue({ + staticServers: { + context7: { type: 'stdio', command: 'node', args: ['context7.js'], tags: ['context7'] }, + }, + templateServers: {}, + errors: [], + }); + const res = createMockResponse(); + + await invokeInspectRoute(scopeAuthMiddleware, { query: { target: 'context7' } }, res); + await invokeInspectRoute(inspectHandler, { query: { target: 'context7' } }, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + kind: 'server', + server: 'context7', + status: 'connected', + available: true, + totalTools: 1, + tools: [{ tool: 'query-docs' }], + }); + }); }); diff --git a/src/transport/http/routes/inspectHelpers.ts b/src/transport/http/routes/inspectHelpers.ts index 8c906375..1b8c72e0 100644 --- a/src/transport/http/routes/inspectHelpers.ts +++ b/src/transport/http/routes/inspectHelpers.ts @@ -16,6 +16,7 @@ import { } from '@src/transport/http/middlewares/scopeAuthMiddleware.js'; import { buildUri, parseUri } from '@src/utils/core/parsing.js'; import { normalizeTag } from '@src/utils/validation/sanitization.js'; +import type { ClientServerStatus } from '@src/types/serverStatus.js'; import { Response } from 'express'; @@ -24,7 +25,7 @@ import { Response } from 'express'; export interface ServerSummary { server: string; type: string; - status: string; + status: ClientServerStatus; available: boolean; /** True only for enabled, configured static servers tracked during startup. */ loadTracked: boolean; @@ -42,7 +43,7 @@ export interface InspectServerPayload { kind: 'server'; server: string; type: string; - status: string; + status: ClientServerStatus; available: boolean; loadTracked: boolean; instructions: string | null; @@ -192,7 +193,7 @@ export function deriveServerState( adapterAvailable: boolean | undefined, connection?: OutboundConnection, loadingInfo?: ServerLoadingInfo, -): { status: string; available: boolean } { +): { status: ClientServerStatus; available: boolean } { // During async startup, the loading tracker is the authoritative source until // a server has reached Ready. A stale connection must not make a loading or // terminal backend look callable. @@ -216,11 +217,24 @@ export function deriveServerState( } return { - status: adapterStatus ?? 'unknown', + status: normalizeAdapterStatus(adapterStatus), available: adapterAvailable ?? false, }; } +function normalizeAdapterStatus(status: string | undefined): ClientServerStatus { + switch (status) { + case 'connected': + case 'initializing': + case 'disconnected': + case 'error': + case 'awaiting_oauth': + return status; + default: + return 'unknown'; + } +} + export function matchesFilterConfig(tags: string[] | undefined, filterConfig: InboundConnectionConfig): boolean { const serverTags = tags ?? []; diff --git a/src/transport/http/routes/inspectRoutes.ts b/src/transport/http/routes/inspectRoutes.ts index d9f21146..35a80dc7 100644 --- a/src/transport/http/routes/inspectRoutes.ts +++ b/src/transport/http/routes/inspectRoutes.ts @@ -435,7 +435,7 @@ export function createInspectHandler(serverManager: ServerManager): RequestHandl // Static startup targets remain inspectable before the first atomic // capability snapshot. Do not force a direct tools/list call while the // loading tracker says the backend is not ready. - if (declaredStaticConfig && (!connection || loadingInfo?.state !== LoadingState.Ready)) { + if (declaredStaticConfig && (!connection || (loadingInfo && loadingInfo.state !== LoadingState.Ready))) { const payload: InspectServerPayload = { kind: 'server', server: serverName, diff --git a/src/types/serverStatus.ts b/src/types/serverStatus.ts new file mode 100644 index 00000000..5b9a526a --- /dev/null +++ b/src/types/serverStatus.ts @@ -0,0 +1,14 @@ +export const CLIENT_SERVER_STATUSES = [ + 'pending', + 'loading', + 'initializing', + 'connected', + 'failed', + 'awaiting_oauth', + 'cancelled', + 'disconnected', + 'error', + 'unknown', +] as const; + +export type ClientServerStatus = (typeof CLIENT_SERVER_STATUSES)[number]; From 914c3ae200ef1d25eaee5f6928fe48275e7193e8 Mon Sep 17 00:00:00 2001 From: Xu Zhipei Date: Wed, 29 Jul 2026 23:29:33 +0800 Subject: [PATCH 3/4] test(cli): update inspect server table contract --- test/e2e/commands/inspect.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/e2e/commands/inspect.test.ts b/test/e2e/commands/inspect.test.ts index f2c22a33..54d45e4d 100644 --- a/test/e2e/commands/inspect.test.ts +++ b/test/e2e/commands/inspect.test.ts @@ -267,8 +267,11 @@ describeInspectE2E('inspect command E2E', () => { runner.assertSuccess(listResult); expect(listResult.stdout).toContain('kind: servers'); - expect(listResult.stdout).toContain('servers[2]{server,type,status,available,toolCount,hasInstructions}:'); - expect(listResult.stdout).toContain('serena,template,disconnected,false,0,true'); + expect(listResult.stdout).toContain( + 'servers[2]{server,type,status,available,loadTracked,toolCount,hasInstructions}:', + ); + expect(listResult.stdout).toContain('runner,external,connected,true,true,4,false'); + expect(listResult.stdout).toContain('serena,template,disconnected,false,false,0,true'); expect(listResult.stdout).not.toContain('# 1MCP - Model Context Protocol Proxy'); const serverResult = await runner.runInspectCommand('serena', { From 798693104cef6e95ec97aefcb1492711c8e7c619 Mon Sep 17 00:00:00 2001 From: Xu Zhipei Date: Thu, 30 Jul 2026 23:11:29 +0800 Subject: [PATCH 4/4] fix(cli): address async wait review findings --- docs/en/commands/index.md | 2 +- docs/en/commands/inspect.md | 2 +- docs/en/commands/wait.md | 4 +- docs/en/guide/advanced/fast-startup.md | 24 ++-- docs/zh/commands/index.md | 2 +- docs/zh/commands/inspect.md | 7 +- docs/zh/commands/run.md | 5 + docs/zh/commands/wait.md | 4 +- docs/zh/guide/advanced/fast-startup.md | 24 ++-- src/commands/run/run.rest-primary.test.ts | 25 ++++ src/commands/run/run.ts | 21 +++- .../shared/clientSurfaceAttachment.test.ts | 10 +- .../shared/clientSurfaceAttachment.ts | 19 +++- src/commands/wait/index.ts | 2 +- src/commands/wait/wait.test.ts | 107 +++++++++++------- src/commands/wait/wait.ts | 74 +++++++++--- src/transport/http/routes/apiRoutes.test.ts | 24 ++++ src/transport/http/routes/inspectRoutes.ts | 12 +- test/unit-utils/MockFactories.ts | 57 ++++++++++ 19 files changed, 321 insertions(+), 104 deletions(-) diff --git a/docs/en/commands/index.md b/docs/en/commands/index.md index 1335391b..52a24485 100644 --- a/docs/en/commands/index.md +++ b/docs/en/commands/index.md @@ -79,7 +79,7 @@ npx -y @1mcp/agent run context7/query-docs --args '{"libraryId":"/mongodb/docs", - **[instructions](/commands/instructions)** - Print the CLI playbook and current servers - **[inspect](/commands/inspect)** - Discover tools and inspect schemas - **[run](/commands/run)** - Execute a tool call -- **[wait](/commands/wait)** - Wait for configured static servers to connect +- **[wait](/commands/wait)** - Wait for configured static servers to become connected and available - **[cli-setup](/commands/cli-setup)** - Install Codex or Claude bootstrap files - **[auth](/commands/auth)** - Manage authentication profiles for secured servers diff --git a/docs/en/commands/inspect.md b/docs/en/commands/inspect.md index 42ae75c0..4016dae2 100644 --- a/docs/en/commands/inspect.md +++ b/docs/en/commands/inspect.md @@ -33,7 +33,7 @@ Depending on the target, `inspect` can: - List the exposed tools for a server when the target is `` - Show a readable tool schema summary when the target is `/` -When supported, `inspect` uses the authenticated `/api/v1/inspect` endpoint first and falls back to the MCP protocol when needed. It reports startup states for configured static servers before the first capability snapshot; use [`wait`](./wait.md) when a script must wait for `connected`. +When supported, `inspect` uses the authenticated `/api/v1/inspect` endpoint first and falls back to the MCP protocol when needed. It reports startup states for configured static servers before the first capability snapshot; use [`wait`](/commands/wait) when a script must wait for `connected` with `available: true`. This is the command that turns the broad inventory from `instructions` into a scoped view. First inspect one server, then inspect one tool, and only then move to execution. diff --git a/docs/en/commands/wait.md b/docs/en/commands/wait.md index 82e72af5..c559b87e 100644 --- a/docs/en/commands/wait.md +++ b/docs/en/commands/wait.md @@ -1,11 +1,11 @@ --- title: Wait Command - Wait for Static MCP Servers -description: Wait for configured static MCP servers to become connected before invoking tools. +description: Wait for configured static MCP servers to become connected and available before invoking tools. --- # Wait Command -Wait for enabled configured static MCP servers to become connected through the authenticated `/api/v1/inspect` status contract. +Wait for enabled configured static MCP servers to become connected and available through the authenticated `/api/v1/inspect` status contract. ```bash npx -y @1mcp/agent wait [server] [options] diff --git a/docs/en/guide/advanced/fast-startup.md b/docs/en/guide/advanced/fast-startup.md index 3d91d4c7..edb7f84c 100644 --- a/docs/en/guide/advanced/fast-startup.md +++ b/docs/en/guide/advanced/fast-startup.md @@ -11,12 +11,12 @@ Async loading makes the HTTP listener available before configured static MCP ser Synchronous startup is the default. It waits to publish a complete initial catalog and is the safe choice unless client behavior is known. -| Client behavior | Recommended startup | Reason | -| --- | --- | --- | -| Reads the catalog once and never refreshes | Synchronous (default) | It requires the complete initial catalog. | -| Handles capability-list change notifications and retries discovery | Async is suitable | It can reconcile after a new snapshot is published. | -| Is gated from using the catalog until loading completes | Async is suitable | The deployment gate protects its first catalog read. | -| Unknown or mixed compatibility | Synchronous (default) | Notification support is unverified. | +| Client behavior | Recommended startup | Reason | +| ------------------------------------------------------------------ | --------------------- | ---------------------------------------------------- | +| Reads the catalog once and never refreshes | Synchronous (default) | It requires the complete initial catalog. | +| Handles capability-list change notifications and retries discovery | Async is suitable | It can reconcile after a new snapshot is published. | +| Is gated from using the catalog until loading completes | Async is suitable | The deployment gate protects its first catalog read. | +| Unknown or mixed compatibility | Synchronous (default) | Notification support is unverified. | Enable async loading only when early HTTP availability is worth this contract: @@ -57,12 +57,12 @@ Use the authenticated `/api/v1/inspect` endpoint or its CLI equivalent for clien `wait` only tracks enabled configured static servers. It excludes templates and disabled servers, never cancels background loading, and stops immediately for `failed`, `cancelled`, or `awaiting_oauth` states. `run` checks this client-facing status before falling back to MCP, so it returns a recovery command while a backend is loading instead of attempting an unsafe early invocation. -| State | Meaning | Action | -| --- | --- | --- | -| `pending` / `loading` | Startup is still in progress | `1mcp wait ` | -| `failed` / `cancelled` | No usable backend is available | Check configuration or restart the backend | -| `awaiting_oauth` | Provider authorization is required | Complete the OAuth flow shown by inspect | -| `connected` | The backend is callable | Inspect or run tools | +| State | Meaning | Action | +| ---------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `pending` / `loading` | Startup is still in progress | `1mcp wait ` | +| `failed` / `cancelled` | No usable backend is available | Check configuration or restart the backend | +| `awaiting_oauth` | Provider authorization is required | Complete the OAuth flow shown by inspect | +| `connected` | The backend connection is established; invocation also requires `available: true` | Inspect or run tools after availability is confirmed | ## Health Endpoints diff --git a/docs/zh/commands/index.md b/docs/zh/commands/index.md index 258f640e..69d0354a 100644 --- a/docs/zh/commands/index.md +++ b/docs/zh/commands/index.md @@ -73,7 +73,7 @@ npx -y @1mcp/agent run context7/query-docs --args '{"libraryId":"/mongodb/docs", - **[instructions](/zh/commands/instructions)** - 打印 CLI playbook 和当前服务器清单 - **[inspect](/zh/commands/inspect)** - 发现工具并查看 schema - **[run](/zh/commands/run)** - 执行工具调用 -- **[wait](/zh/commands/wait)** - 等待已配置静态服务器连接 +- **[wait](/zh/commands/wait)** - 等待已配置静态服务器连接且可用 - **[cli-setup](/zh/commands/cli-setup)** - 安装 Codex 或 Claude 的引导文件 - **[auth](/zh/commands/auth)** - 管理受保护服务器的认证配置 diff --git a/docs/zh/commands/inspect.md b/docs/zh/commands/inspect.md index 6d469eae..ce194120 100644 --- a/docs/zh/commands/inspect.md +++ b/docs/zh/commands/inspect.md @@ -1,3 +1,8 @@ +--- +title: Inspect 命令 - 发现服务器和工具 Schema +description: 使用 inspect 命令列出服务器、检查服务器工具,并查看运行中 1MCP serve 实例的工具 schema。 +--- + # Inspect 命令 检查运行中的 1MCP `serve` 实例所暴露的服务器和工具。 @@ -18,7 +23,7 @@ npx -y @1mcp/agent inspect [target] [选项] - 在 target 为 `` 时列出该服务器的工具 - 在 target 为 `/` 时输出工具 schema 摘要 -支持时,`inspect` 会优先使用已认证的 `/api/v1/inspect` 端点,否则回退到 MCP 协议。它会在首个能力快照发布前报告已配置静态服务器的启动状态;脚本需要等待 `connected` 时请使用 [`wait`](./wait.md)。 +支持时,`inspect` 会优先使用已认证的 `/api/v1/inspect` 端点,否则回退到 MCP 协议。它会在首个能力快照发布前报告已配置静态服务器的启动状态;脚本需要等待 `connected` 且 `available: true` 时请使用 [`wait`](/zh/commands/wait)。 它的核心作用,是把 `instructions` 提供的全局清单进一步收窄成一个 server,或者一个具体 tool 的 schema。先检查 server,再检查 tool,最后才进入执行。 diff --git a/docs/zh/commands/run.md b/docs/zh/commands/run.md index 1c4c550a..c415f75a 100644 --- a/docs/zh/commands/run.md +++ b/docs/zh/commands/run.md @@ -1,3 +1,8 @@ +--- +title: Run 命令 - 通过 1MCP 调用工具 +description: 使用 run 命令针对运行中的 1MCP serve 实例调用 MCP 工具,并了解参数、stdin 映射和输出格式。 +--- + # Run 命令 通过运行中的 1MCP `serve` 实例调用 MCP 工具。 diff --git a/docs/zh/commands/wait.md b/docs/zh/commands/wait.md index 46a68b9f..8acc6a5e 100644 --- a/docs/zh/commands/wait.md +++ b/docs/zh/commands/wait.md @@ -1,11 +1,11 @@ --- title: Wait 命令 - 等待静态 MCP 服务器 -description: 在调用工具前等待已配置的静态 MCP 服务器连接完成。 +description: 在调用工具前等待已配置的静态 MCP 服务器连接完成且可用。 --- # Wait 命令 -通过已认证的 `/api/v1/inspect` 状态契约,等待已启用的已配置静态 MCP 服务器变为 connected。 +通过已认证的 `/api/v1/inspect` 状态契约,等待已启用的已配置静态 MCP 服务器变为 `connected` 且 `available: true`。 ```bash npx -y @1mcp/agent wait [server] [options] diff --git a/docs/zh/guide/advanced/fast-startup.md b/docs/zh/guide/advanced/fast-startup.md index 0c972d7d..22d12d6b 100644 --- a/docs/zh/guide/advanced/fast-startup.md +++ b/docs/zh/guide/advanced/fast-startup.md @@ -11,12 +11,12 @@ description: 选择同步或异步 MCP 服务器启动,并安全监控面向 同步启动是默认模式。它会等待发布完整的初始目录;除非已知客户端行为,否则应选择同步模式。 -| 客户端行为 | 建议启动方式 | 原因 | -| --- | --- | --- | -| 只读取一次目录且不会刷新 | 同步(默认) | 需要完整的初始目录。 | -| 能处理能力列表变更通知并重试发现 | 可使用异步 | 可在发布新快照后重新协调。 | -| 在加载完成前不会使用目录 | 可使用异步 | 部署门禁保护首次目录读取。 | -| 兼容性未知或混合 | 同步(默认) | 通知支持尚未验证。 | +| 客户端行为 | 建议启动方式 | 原因 | +| -------------------------------- | ------------ | -------------------------- | +| 只读取一次目录且不会刷新 | 同步(默认) | 需要完整的初始目录。 | +| 能处理能力列表变更通知并重试发现 | 可使用异步 | 可在发布新快照后重新协调。 | +| 在加载完成前不会使用目录 | 可使用异步 | 部署门禁保护首次目录读取。 | +| 兼容性未知或混合 | 同步(默认) | 通知支持尚未验证。 | 只有在早期 HTTP 可用性值得采用该契约时,才启用异步加载: @@ -57,12 +57,12 @@ sequenceDiagram `wait` 只跟踪已启用的已配置静态服务器,会排除模板和已禁用服务器,绝不会取消后台加载;遇到 `failed`、`cancelled` 或 `awaiting_oauth` 会立即结束。`run` 会在回退到 MCP 前检查该面向客户端的状态,因此后端仍在加载时会返回恢复命令,而不会进行不安全的提前调用。 -| 状态 | 含义 | 操作 | -| --- | --- | --- | -| `pending` / `loading` | 启动仍在进行 | `1mcp wait ` | -| `failed` / `cancelled` | 没有可用后端 | 检查配置或重启后端 | -| `awaiting_oauth` | 需要提供方授权 | 完成 inspect 显示的 OAuth 流程 | -| `connected` | 后端可调用 | 检查或运行工具 | +| 状态 | 含义 | 操作 | +| ---------------------- | -------------------------------------------- | ------------------------------ | +| `pending` / `loading` | 启动仍在进行 | `1mcp wait ` | +| `failed` / `cancelled` | 没有可用后端 | 检查配置或重启后端 | +| `awaiting_oauth` | 需要提供方授权 | 完成 inspect 显示的 OAuth 流程 | +| `connected` | 后端连接已建立;调用还要求 `available: true` | 确认可用后再检查或运行工具 | ## 健康检查端点 diff --git a/src/commands/run/run.rest-primary.test.ts b/src/commands/run/run.rest-primary.test.ts index 4a04e24f..b9aa96b3 100644 --- a/src/commands/run/run.rest-primary.test.ts +++ b/src/commands/run/run.rest-primary.test.ts @@ -406,6 +406,31 @@ describe('runCommand REST-first path', () => { expect(stderr.join('')).toContain('1mcp wait runner'); }); + it('waits instead of restarting when a connected server is not yet available', async () => { + mockFetch.mockResolvedValueOnce( + makeRestResponse(200, { kind: 'server', server: 'runner', status: 'connected', available: false }), + ); + const stderr: string[] = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + + const { runCommand } = await import('./run.js'); + await runCommand({ + tool: 'runner/echo_args', + args: '{"message":"hi"}', + 'config-dir': cacheDir, + 'cli-session-cache-path': join(cacheDir, '.cli-session.{pid}'), + } as never); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(transportState.instances).toHaveLength(0); + expect(stderr.join('')).toContain("Server 'runner' is connected but not yet available"); + expect(stderr.join('')).toContain('1mcp wait runner'); + expect(stderr.join('')).not.toContain('1mcp mcp restart runner'); + }); + it('checks status with the same preset used by the invocation surface', async () => { mockFetch.mockResolvedValueOnce(makeConnectedServerResponse()); mockFetch.mockResolvedValueOnce(makeTextResponse(404, 'Not Found')); diff --git a/src/commands/run/run.ts b/src/commands/run/run.ts index ea8eea2b..021de181 100644 --- a/src/commands/run/run.ts +++ b/src/commands/run/run.ts @@ -14,18 +14,18 @@ import { validateToolArgs, } from '@src/commands/run/runUtils.js'; import { ApiClient } from '@src/commands/shared/apiClient.js'; -import { buildFilterSelectionQuery } from '@src/commands/shared/filterSelectionQuery.js'; -import { - type ApiInspectServerResult, - inspectServerResultSchema, - inspectToolResultSchema, -} from '@src/commands/shared/inspectApiSchemas.js'; import { attachReusableClientSurface, type ClientSurfaceAttachmentContext, type ClientSurfaceRestResponse, formatClientSurfaceAuthRequiredMessage, } from '@src/commands/shared/clientSurfaceAttachment.js'; +import { buildFilterSelectionQuery } from '@src/commands/shared/filterSelectionQuery.js'; +import { + type ApiInspectServerResult, + inspectServerResultSchema, + inspectToolResultSchema, +} from '@src/commands/shared/inspectApiSchemas.js'; import { type JsonRpcErrorEnvelope, type JsonRpcResponse, @@ -273,6 +273,15 @@ async function checkServerStatus( ); } + if (server.status === 'connected' && !server.available) { + return statusErrorResponse( + 'server_loading', + `Server '${serverName}' is connected but not yet available. Wait for it to finish loading.`, + `1mcp wait ${serverName}`, + server, + ); + } + if (server.status === 'awaiting_oauth') { const guidance = server.authorizationUrl ? `Complete OAuth authorization at ${server.authorizationUrl}.` diff --git a/src/commands/shared/clientSurfaceAttachment.test.ts b/src/commands/shared/clientSurfaceAttachment.test.ts index 1389b3c3..cce37591 100644 --- a/src/commands/shared/clientSurfaceAttachment.test.ts +++ b/src/commands/shared/clientSurfaceAttachment.test.ts @@ -1,3 +1,5 @@ +import { createMockCliSessionCache } from '@test/unit-utils/MockFactories.js'; + import type { ProjectConfig } from '@src/config/projectConfigTypes.js'; import type { ContextData } from '@src/types/context.js'; @@ -193,13 +195,7 @@ describe('attachReusableClientSurface', () => { it('can require a fresh REST status check despite a negative cache hint', async () => { const ports = makePorts({ - cachedSession: { - sessionId: 'cached-session', - serverUrl: 'http://127.0.0.1:3050/mcp', - contextHash: 'hash-from-port', - savedAt: 1000, - hasRestEndpoint: false, - }, + cachedSession: createMockCliSessionCache({ hasRestEndpoint: false }), }); const rest = vi.fn(async () => ({ status: 'success' as const, value: 'rest-value' })); const mcp = unusedAdapter(); diff --git a/src/commands/shared/clientSurfaceAttachment.ts b/src/commands/shared/clientSurfaceAttachment.ts index ab1aa99a..90290b47 100644 --- a/src/commands/shared/clientSurfaceAttachment.ts +++ b/src/commands/shared/clientSurfaceAttachment.ts @@ -427,15 +427,28 @@ async function loadBearerToken( export function formatClientSurfaceAuthRequiredMessage( context: ClientSurfaceAuthRequiredContext, ): string { + const recoveryCommand = getClientSurfaceAuthRecoveryCommand(context); if (context.target.runtimeTargetContext) { - return `Authentication required for target context "${context.target.runtimeTargetContext.name}". Run: 1mcp auth login --context ${context.target.runtimeTargetContext.name} --token `; + return `Authentication required for target context "${context.target.runtimeTargetContext.name}". Run: ${recoveryCommand}`; } if (context.options.url) { - return `Authentication required for ephemeral URL target. Ephemeral URLs are credentialless; run: 1mcp target add ${context.baseUrl} and retry with --context after context-scoped credentials are available.`; + return `Authentication required for ephemeral URL target. Ephemeral URLs are credentialless; run: ${recoveryCommand} and retry with --context after context-scoped credentials are available.`; } - return 'Authentication required. Run: 1mcp auth login --context local --token '; + return `Authentication required. Run: ${recoveryCommand}`; +} + +export function getClientSurfaceAuthRecoveryCommand( + context: ClientSurfaceAuthRequiredContext, +): string { + if (context.target.runtimeTargetContext) { + return `1mcp auth login --context ${context.target.runtimeTargetContext.name} --token `; + } + if (context.options.url) { + return `1mcp target add ${context.baseUrl}`; + } + return '1mcp auth login --context local --token '; } function toOAuthTokenReference(value: unknown): { token: string } | undefined { diff --git a/src/commands/wait/index.ts b/src/commands/wait/index.ts index 11aabcf7..49dcf03b 100644 --- a/src/commands/wait/index.ts +++ b/src/commands/wait/index.ts @@ -6,7 +6,7 @@ import type { Argv } from 'yargs'; export function setupWaitCommand(yargs: Argv): Argv { return yargs.command( 'wait [server]', - 'Wait for configured static MCP servers to become connected', + 'Wait for configured static MCP servers to become connected and available', (commandYargs) => commandYargs .options(globalOptions || {}) diff --git a/src/commands/wait/wait.test.ts b/src/commands/wait/wait.test.ts index f43cc7da..b771f80a 100644 --- a/src/commands/wait/wait.test.ts +++ b/src/commands/wait/wait.test.ts @@ -1,33 +1,25 @@ +import { + createMockClientSurfaceAttachmentContext, + createMockInspectServerSummary, +} from '@test/unit-utils/MockFactories.js'; + import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { ClientSurfaceAttachmentContext } from '@src/commands/shared/clientSurfaceAttachment.js'; - -import { selectWaitServers, waitForServers, WaitCommandError, type WaitCommandOptions } from './wait.js'; - -const baseContext: ClientSurfaceAttachmentContext = { - target: { - cwd: '/tmp/project', - projectRoot: '/tmp/project', - projectConfig: null, - mergedOptions: {}, - discoveredUrl: 'http://127.0.0.1:3050/mcp', - serverUrl: new URL('http://127.0.0.1:3050/mcp'), - source: 'user', - }, - options: {}, - baseUrl: 'http://127.0.0.1:3050', - serverUrl: new URL('http://127.0.0.1:3050/mcp'), - context: { - project: { path: '/tmp/project', cwd: '/tmp/project', name: 'project' }, - user: {}, - environment: {}, - }, +import { selectWaitServers, waitCommand, WaitCommandError, type WaitCommandOptions, waitForServers } from './wait.js'; + +const mockedAttachReusableClientSurface = vi.hoisted(() => vi.fn()); + +vi.mock('@src/commands/shared/clientSurfaceAttachment.js', async (importOriginal) => ({ + ...(await importOriginal()), + attachReusableClientSurface: mockedAttachReusableClientSurface, +})); + +const baseContext = createMockClientSurfaceAttachmentContext({ contextHash: 'wait-test', cachePath: '/tmp/wait-test', - cachedSession: null, requestSessionId: 'wait-session', sessionId: 'wait-session', -}; +}); function jsonResponse(body: unknown, status = 200) { return { @@ -39,17 +31,53 @@ function jsonResponse(body: unknown, status = 200) { } describe('wait status workflow', () => { - afterEach(() => vi.unstubAllGlobals()); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + mockedAttachReusableClientSurface.mockReset(); + }); - it('waits only for enabled configured static servers and succeeds when connected', async () => { + it('validates the complete wait option boundary before attachment', async () => { + await expect(waitCommand({ format: 'xml' } as never)).rejects.toMatchObject({ + code: 'validation_options', + recoveryCommand: '1mcp wait', + }); + expect(mockedAttachReusableClientSurface).not.toHaveBeenCalled(); + }); + + it.each([0, Number.NaN, Number.POSITIVE_INFINITY])( + 'preserves the positive finite timeout validation contract for %s', + async (timeout) => { + await expect(waitCommand({ timeout })).rejects.toMatchObject({ code: 'validation_timeout' }); + expect(mockedAttachReusableClientSurface).not.toHaveBeenCalled(); + }, + ); + + it('preserves context-aware authentication recovery', async () => { + mockedAttachReusableClientSurface.mockResolvedValue({ + status: 'auth_required', + message: + 'Authentication required for target context "prod". Run: 1mcp auth login --context prod --token ', + target: { runtimeTargetContext: { name: 'prod', kind: 'remote', runtimeScopeId: 'scope-prod' } }, + options: { context: 'prod' }, + baseUrl: 'https://prod.example.com', + }); + + await expect(waitCommand({ context: 'prod' })).rejects.toMatchObject({ + code: 'auth_required', + recoveryCommand: '1mcp auth login --context prod --token ', + }); + }); + + it('waits only for enabled configured static servers and succeeds when connected and available', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( jsonResponse({ kind: 'servers', servers: [ - { server: 'static', type: 'external', status: 'connected', available: true, loadTracked: true }, - { server: 'template', type: 'template', status: 'connected', available: true, loadTracked: false }, + createMockInspectServerSummary({ server: 'static' }), + createMockInspectServerSummary({ server: 'template', type: 'template', loadTracked: false }), ], }), ), @@ -66,7 +94,7 @@ describe('wait status workflow', () => { it('rejects a template target without waiting', () => { try { selectWaitServers( - [{ server: 'template', type: 'template', status: 'connected', available: true, loadTracked: false }], + [createMockInspectServerSummary({ server: 'template', type: 'template', loadTracked: false })], 'template', ); throw new Error('Expected template target to be rejected'); @@ -82,7 +110,7 @@ describe('wait status workflow', () => { vi.fn().mockResolvedValue( jsonResponse({ kind: 'servers', - servers: [{ server: 'static', type: 'external', status: 'failed', available: false, loadTracked: true }], + servers: [createMockInspectServerSummary({ server: 'static', status: 'failed', available: false })], }), ), ); @@ -99,13 +127,13 @@ describe('wait status workflow', () => { .mockResolvedValueOnce( jsonResponse({ kind: 'servers', - servers: [{ server: 'static', type: 'external', status: 'pending', available: false, loadTracked: true }], + servers: [createMockInspectServerSummary({ server: 'static', status: 'pending', available: false })], }), ) .mockResolvedValueOnce( jsonResponse({ kind: 'servers', - servers: [{ server: 'static', type: 'external', status: 'connected', available: true, loadTracked: true }], + servers: [createMockInspectServerSummary({ server: 'static' })], }), ); vi.stubGlobal('fetch', fetchMock); @@ -120,7 +148,7 @@ describe('wait status workflow', () => { vi.fn().mockResolvedValue( jsonResponse({ kind: 'servers', - servers: [{ server: 'static', type: 'external', status, available: false, loadTracked: true }], + servers: [createMockInspectServerSummary({ server: 'static', status, available: false })], }), ), ); @@ -134,7 +162,7 @@ describe('wait status workflow', () => { const fetchMock = vi.fn().mockResolvedValue( jsonResponse({ kind: 'servers', - servers: [{ server: 'static', type: 'external', status: 'connected', available: true, loadTracked: true }], + servers: [createMockInspectServerSummary({ server: 'static' })], }), ); vi.stubGlobal('fetch', fetchMock); @@ -156,10 +184,13 @@ describe('wait status workflow', () => { it('aborts an inspect request at the CLI deadline and preserves timeout state', async () => { vi.stubGlobal( 'fetch', - vi.fn((_url: string, init: RequestInit) => - new Promise((_resolve, reject) => { - init.signal?.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))); - }), + vi.fn( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), ), ); diff --git a/src/commands/wait/wait.ts b/src/commands/wait/wait.ts index 71e4a1f0..8d98deed 100644 --- a/src/commands/wait/wait.ts +++ b/src/commands/wait/wait.ts @@ -1,19 +1,19 @@ import { encode } from '@toon-format/toon'; import { ApiClient } from '@src/commands/shared/apiClient.js'; -import { buildFilterSelectionQuery } from '@src/commands/shared/filterSelectionQuery.js'; -import { - type InspectServerSummary, - inspectServersResultSchema, -} from '@src/commands/shared/inspectApiSchemas.js'; import { attachReusableClientSurface, type ClientSurfaceAttachmentContext, formatClientSurfaceAuthRequiredMessage, + getClientSurfaceAuthRecoveryCommand, } from '@src/commands/shared/clientSurfaceAttachment.js'; +import { buildFilterSelectionQuery } from '@src/commands/shared/filterSelectionQuery.js'; +import { inspectServersResultSchema, type InspectServerSummary } from '@src/commands/shared/inspectApiSchemas.js'; import { API_INSPECT_ENDPOINT } from '@src/constants/api.js'; import type { GlobalOptions } from '@src/globalOptions.js'; +import { z } from 'zod'; + export interface WaitCommandOptions extends GlobalOptions { server?: string; url?: string; @@ -26,6 +26,25 @@ export interface WaitCommandOptions extends GlobalOptions { format?: 'toon' | 'text' | 'json'; } +const waitCommandOptionsSchema = z + .object({ + server: z.string().optional(), + url: z.string().optional(), + context: z.string().optional(), + preset: z.string().optional(), + filter: z.string().optional(), + tags: z.array(z.string()).optional(), + 'tag-filter': z.string().optional(), + timeout: z.number().finite().optional(), + format: z.enum(['toon', 'text', 'json']).optional(), + config: z.string().optional(), + 'config-dir': z.string().optional(), + 'cli-session-cache-path': z.string().optional(), + 'log-level': z.enum(['debug', 'info', 'warn', 'error']).optional(), + 'log-file': z.string().optional(), + }) + .passthrough(); + interface WaitResult { kind: 'wait'; servers: InspectServerSummary[]; @@ -45,19 +64,29 @@ export class WaitCommandError extends Error { } export async function waitCommand(options: WaitCommandOptions): Promise { - const timeout = options.timeout ?? 30_000; - if (!Number.isFinite(timeout) || timeout <= 0) { + const parsedOptions = waitCommandOptionsSchema.safeParse(options); + if (!parsedOptions.success) { + if (parsedOptions.error.issues.some((issue) => issue.path[0] === 'timeout')) { + throwInvalidTimeout(); + } throw new WaitCommandError( - 'validation_timeout', - '--timeout must be a positive number of milliseconds.', + 'validation_options', + `Invalid wait options: ${parsedOptions.error.issues[0]?.message ?? 'validation failed'}`, '1mcp wait', + { issues: parsedOptions.error.issues }, ); } + const normalizedOptions = parsedOptions.data; + const timeout = normalizedOptions.timeout ?? 30_000; + if (!Number.isFinite(timeout) || timeout <= 0) { + throwInvalidTimeout(); + } + const attachment = await attachReusableClientSurface({ clientSurface: 'wait', version: 'wait', - options, + options: normalizedOptions, alwaysTryRest: true, rest: (context) => waitForServers(context, timeout), // `wait` deliberately has no MCP fallback: /api/v1/inspect is the @@ -66,16 +95,31 @@ export async function waitCommand(options: WaitCommandOptions): Promise { mcp: async () => ({ status: 'error', message: 'The running 1MCP server does not support /api/v1/inspect.' }), }); + if (attachment.status === 'auth_required') { + throw new WaitCommandError( + 'auth_required', + attachment.message, + getClientSurfaceAuthRecoveryCommand({ + baseUrl: attachment.baseUrl, + options: normalizedOptions, + target: attachment.target, + }), + ); + } if (attachment.status !== 'success') { throw new WaitCommandError('server_status_unavailable', attachment.message, '1mcp inspect'); } - const output = formatWaitOutput(attachment.value, options.format ?? 'toon'); + const output = formatWaitOutput(attachment.value, normalizedOptions.format ?? 'toon'); if (output.length > 0) { process.stdout.write(`${output}\n`); } } +function throwInvalidTimeout(): never { + throw new WaitCommandError('validation_timeout', '--timeout must be a positive number of milliseconds.', '1mcp wait'); +} + export async function waitForServers( context: ClientSurfaceAttachmentContext, timeout: number, @@ -99,11 +143,9 @@ export async function waitForServers( if (remaining <= 0) { throwWaitTimeout(timeout, context.options.server, lastServers); } - const response = await apiClient.get( - API_INSPECT_ENDPOINT, - buildFilterSelectionQuery(context.options), - { timeout: remaining }, - ); + const response = await apiClient.get(API_INSPECT_ENDPOINT, buildFilterSelectionQuery(context.options), { + timeout: remaining, + }); if (response.status === 401 || response.status === 403) { return { status: 'auth_required', message: formatClientSurfaceAuthRequiredMessage(context) }; } diff --git a/src/transport/http/routes/apiRoutes.test.ts b/src/transport/http/routes/apiRoutes.test.ts index 9d90167e..08b06e17 100644 --- a/src/transport/http/routes/apiRoutes.test.ts +++ b/src/transport/http/routes/apiRoutes.test.ts @@ -408,6 +408,30 @@ describe('apiRoutes inspect', () => { expect(res.body).toMatchObject({ error: 'Tool not found: hidden/secret' }); }); + it('does not expose loading metadata for filtered-out static servers', async () => { + mockedLoadDeclaredServerConfigs.mockReturnValue({ + staticServers: { + hidden: { + type: 'stdio', + command: 'node', + args: ['hidden.js'], + tags: ['hidden'], + }, + }, + templateServers: {}, + errors: [], + }); + + const req = { query: { preset: 'dev-backend', target: 'hidden' } }; + const res = createMockResponse(); + + await invokeInspectRoute(scopeAuthMiddleware, req, res); + await invokeInspectRoute(inspectHandler, req, res); + + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: 'Server not found: hidden' }); + }); + it('preserves pagination metadata when inspecting a server through direct listTools', async () => { const pagedConnections = new Map(outboundConnections) as OutboundConnections; pagedConnections.set('context7', { diff --git a/src/transport/http/routes/inspectRoutes.ts b/src/transport/http/routes/inspectRoutes.ts index 35a80dc7..2955a560 100644 --- a/src/transport/http/routes/inspectRoutes.ts +++ b/src/transport/http/routes/inspectRoutes.ts @@ -415,13 +415,23 @@ export function createInspectHandler(serverManager: ServerManager): RequestHandl const connection = sessionConnection ?? resolveConnectionByServerName(filteredConnections, serverName); const declaredTemplateConfig = declaredServers.templateServers[serverName]; const declaredStaticConfig = declaredServers.staticServers[serverName]; - const loadingInfo = getLoadingInfo(serverName); if (!adapter && !connection && !declaredTemplateConfig && !declaredStaticConfig) { res.status(404).json({ error: `Server not found: ${serverName}` }); return; } + const declaredConfig = declaredTemplateConfig ?? declaredStaticConfig; + const targetAllowed = + !!connection || + matchesFilterConfig(adapter?.config.tags, filterConfig) || + matchesFilterConfig(declaredConfig?.tags, filterConfig); + if (!targetAllowed) { + res.status(404).json({ error: `Server not found: ${serverName}` }); + return; + } + + const loadingInfo = getLoadingInfo(serverName); const state = deriveServerState( adapter?.getStatus(requestSessionId ? { sessionId: requestSessionId } : undefined), adapter?.isAvailable(requestSessionId ? { sessionId: requestSessionId } : undefined), diff --git a/test/unit-utils/MockFactories.ts b/test/unit-utils/MockFactories.ts index c02791e6..33472dd3 100644 --- a/test/unit-utils/MockFactories.ts +++ b/test/unit-utils/MockFactories.ts @@ -9,6 +9,10 @@ import { ServerStatus, } from '@src/../src/core/types/index.js'; import { ClientSessionData } from '@src/auth/sessionTypes.js'; +import type { ClientSurfaceAttachmentContext } from '@src/commands/shared/clientSurfaceAttachment.js'; +import type { InspectServerSummary } from '@src/commands/shared/inspectApiSchemas.js'; +import type { CliSessionCache } from '@src/commands/shared/serveClient.js'; +import type { ResolvableServeTargetOptions } from '@src/commands/shared/serveTargetResolver.js'; import { vi } from 'vitest'; @@ -120,6 +124,59 @@ export const createMockClientSessionData = (overrides?: Partial): CliSessionCache { + return { + sessionId: 'cached-session', + serverUrl: 'http://127.0.0.1:3050/mcp', + contextHash: 'hash-from-port', + savedAt: 1000, + hasRestEndpoint: true, + ...overrides, + }; +} + +export function createMockClientSurfaceAttachmentContext( + overrides: Partial> = {}, +): ClientSurfaceAttachmentContext { + const options = (overrides.options ?? {}) as TOptions; + return { + target: { + cwd: '/tmp/project', + projectRoot: '/tmp/project', + projectConfig: null, + mergedOptions: options, + discoveredUrl: 'http://127.0.0.1:3050/mcp', + serverUrl: new URL('http://127.0.0.1:3050/mcp'), + source: 'user', + }, + options, + baseUrl: 'http://127.0.0.1:3050', + serverUrl: new URL('http://127.0.0.1:3050/mcp'), + context: { + project: { path: '/tmp/project', cwd: '/tmp/project', name: 'project' }, + user: {}, + environment: {}, + }, + contextHash: 'attachment-test', + cachePath: '/tmp/attachment-test', + cachedSession: null, + requestSessionId: 'attachment-session', + sessionId: 'attachment-session', + ...overrides, + }; +} + +export function createMockInspectServerSummary(overrides?: Partial): InspectServerSummary { + return { + server: 'test-server', + type: 'external', + status: 'connected', + available: true, + loadTracked: true, + ...overrides, + }; +} + /** * Factory for creating mock Express request objects */