SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+1
View File
@@ -0,0 +1 @@
dist/
+391
View File
@@ -0,0 +1,391 @@
# MCP agent features
These additions preserve the existing tool names, action parameters, text results,
and read/write gates. No endpoint is contacted by catalog tools or by a local
runtime overview. The action catalog remains a bundled snapshot, not a claim
about the connected server's capabilities.
## Protocol compatibility and catalog caching
The same `snowluma-mcp` stdio command accepts MCP `2026-07-28` clients and
older clients using the `initialize` handshake. The official server SDK selects
the protocol for the connection. Modern clients can use `server/discover` and
per-request metadata without initialization; legacy clients keep their existing
configuration. This does not add a publicly listening MCP HTTP endpoint.
Modern replies carry the protocol's completion and server metadata. Cache hints
depend on whether a response contains static catalog data or private live state:
| Response | Lifetime | Scope |
| ----------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------ |
| `tools/list` | Five minutes | `private`; mode and client capabilities affect tool metadata and availability. |
| `resources/list` | Five minutes, or zero when events are enabled | `private`; UI capability and active event resources affect the list. |
| `resources/templates/list` | Five minutes | `public`; the bundled template list is static. |
| `resources/read` for the action catalog or message UI | Five minutes | `public`; these are bundled static assets. |
| `resources/read` for event resources | Zero | `private`; these contain account events and connection state. |
These are client cache hints, not a server-side result cache. Tool calls,
including the runtime overview, never receive these hints. The SDK omits
modern-only result fields for legacy clients. Unsupported protocol revisions
are rejected before action execution; they do not trigger an action retry.
## Call tracing
Every tool invocation gets a W3C trace context. Clients can supply `traceparent`
and optional `tracestate` in the tool request's `_meta`:
```json
{
"name": "query_action",
"arguments": { "action": "get_status" },
"_meta": {
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
}
```
MCP preserves a valid caller trace ID and creates a new span ID for each call.
Without a valid parent it creates an independent trace. The returned
`_meta.traceparent` identifies that call, including tool errors; the existing
text and structured result envelopes remain unchanged. This works with both
protocol generations.
Ordinary requests, streams, automatic upload chunks, and upload cleanup forward
the call context in HTTP headers, never in OneBot action parameters. Concurrent
calls keep independent headers. To follow a call, match its traceparent in MCP
stderr diagnostics with SnowLuma's `http_trace` entry, then follow that entry's
existing `req#` through action lifecycle logs. SnowLuma correlation requires
TRACE logging and a valid authenticated HTTP request; the remote trace ID does
not replace its local request ID. No telemetry exporter is installed.
MCP's call diagnostics contain the known tool name, traceparent, outcome, failure
category and duration, not message contents, tokens, file paths or arbitrary metadata.
`tracestate` is validated with a maximum of 512 characters and 32 members; it is
forwarded but not logged. Invalid trace metadata produces a fixed diagnostic and
does not reject an otherwise valid action. `baggage` is neither forwarded nor
logged. Trace identifiers are correlation data, not authentication or permission.
## Optional execution services
Existing configurations keep working. Input completion and message previews are
additive tools; event connections and durable tasks require explicit configuration.
For example, set these variables in the MCP client's server environment, replacing
addresses, credentials and paths with the intended SnowLuma instance:
```json
{
"SNOWLUMA_MCP_MODE": "write",
"SNOWLUMA_MCP_ENDPOINT": "http://127.0.0.1:3000/",
"SNOWLUMA_MCP_TOKEN": "<your access token>",
"SNOWLUMA_MCP_EVENTS_URL": "ws://127.0.0.1:3001/event",
"SNOWLUMA_MCP_TASK_DIR": "/absolute/private/mcp-tasks",
"SNOWLUMA_MCP_UPLOAD_ROOT": "/absolute/allowed/uploads"
}
```
The HTTP and WebSocket addresses are independently configured server endpoints;
the WebSocket address is never guessed from the HTTP address. Events reuse
`SNOWLUMA_MCP_TOKEN` in an authorization header. Upload and task directories are
on the MCP host. Omit services that are not needed. Docs mode remains offline.
## Completing missing parameters
`query_action_with_input` and `invoke_action_with_input` use the same arguments,
action lookup and mode gates as `query_action` and `invoke_action`. The existing
tools continue to behave exactly as before. The additional tools ask for input
only when a field listed in the action catalog's top-level `required` array is
absent; they do not infer conditional requirements or optional values.
For example, `invoke_action_with_input` with
`{"action":"send_group_msg","params":{"group_id":20001}}` asks for the
missing message. The form contains one `params_json` string, whose contents are
a JSON object such as `{"message":"Hello"}`. The answer is validated against
the missing fields' catalog schemas before normal action execution. Already
provided fields, the action name and execution options cannot be overwritten;
extra answer fields are rejected. A declined, cancelled or invalid answer does
not execute the action.
Clients must advertise form elicitation support. Modern clients receive
`resultType: "input_required"` and resume using the SDK's request-state and input
response mechanism. Legacy clients with elicitation support use the SDK's
`elicitation/create` flow. Clients without form support receive a tool error and
can provide complete arguments to the original tools instead.
Continuation state is signed, tied to the original tool and arguments, expires
after ten minutes and is consumed once before execution. Concurrent or repeated
answers cannot execute the same continuation twice. A runtime holds at most 256
pending prompts; restart invalidates them rather than replaying work. Completed
arguments bypass the input round entirely. This feature is not a general approval
gate and does not add confirmation prompts to existing action calls.
## Live event resources
With `SNOWLUMA_MCP_EVENTS_URL` configured, read and write modes expose
`subscribe_events` and `unsubscribe_events`. The URL must use `ws:` or `wss:`
without embedded credentials, query parameters or fragments. Listing tools or
resources does not connect; only `subscribe_events` opens the event connection.
```json
{
"self_id": 10001,
"types": ["message", "notice"],
"group_ids": [20001],
"user_ids": [30001]
}
```
Use the expected logged-in account for required `self_id`; obtain it through
`get_login_info` if necessary. Every incoming event must match this account.
An account mismatch or malformed event stops the connection and is surfaced.
The optional types are `message`, `notice` and `request`; omitted types mean all
three. `message_sent` is included in the message category. Group and user filters
are combined with AND, so a group filter excludes events without a matching
`group_id`. Each ID filter accepts at most 100 distinct positive integers.
The result contains a canonical `snowluma://onebot/events/<id>` resource URI.
Read it with `resources/read`; use `?after=<next_cursor>` on subsequent reads to
obtain newer buffered entries. Cursors belong to that one resource. The response
includes `status`, `next_cursor`, `oldest_cursor`, `dropped_events`, `gap` and
`notification_failed`. Reads do not consume events. Only events received after
subscription creation are available; this is not message-history storage.
Each resource retains at most 256 events and 1 MiB of event data. One runtime
supports at most 16 resources, sharing one account connection. Incoming frames
are limited to 256 KiB. Buffer eviction and disconnection remain visible through
the cursor/gap and status fields; the service does not silently reconnect or
claim missing events were recovered. Release existing resources before changing
accounts or creating a new connection after disconnection.
For updates, use the **canonical URI without `?after`**:
- Modern clients use `subscriptions/listen` with `resourceSubscriptions: [uri]`
(the TypeScript SDK exposes `client.listen(...)`). Closing that notification
stream does not release the event buffer; call `unsubscribe_events` explicitly.
- Legacy clients first call `resources/subscribe` with the canonical URI, then
receive `notifications/resources/updated`. `resources/unsubscribe` also releases
that event resource. The explicit unsubscribe tool works for both generations.
An update notification indicates that the resource changed; fetch the resource
to read its data. Releasing the last resource closes the WebSocket. Closing the
MCP connection releases its event resources and buffers. No event operation
executes a OneBot business action or sends a QQ message.
## Persistent background transfers
Set an absolute `SNOWLUMA_MCP_TASK_DIR` and use write mode to enable persistent
tasks. Task tools additionally require MCP `2026-07-28` clients advertising
`extensions: {"io.modelcontextprotocol/tasks": {}}`. Legacy clients keep the
existing synchronous execution tools; this server does not expose the obsolete
`tasks/list` or `tasks/result` protocol methods.
`invoke_action_background` takes the same arguments as `invoke_action`, but only
accepts catalog actions marked as streams. For example, it can run
`upload_file_stream` with `execution.input_file` inside the configured upload root.
It writes the task record before starting work and returns a `resultType: "task"`
handle. Ordinary actions continue through the existing tools. The current
background transfer entry does not automatically combine with `_with_input`;
provide the required transfer parameters before starting it.
- `tasks/get` with `{ "taskId": "..." }` returns current state and, when finished,
the saved result or error. A `completed` task can contain a failed business
response: inspect the nested result's diagnostics, `status` and `retcode`.
- `tasks/cancel` requests cooperative cancellation. Cancellation does not undo
remote effects, and a successful completion already in flight may win the race.
Poll the task again for its final state.
- `tasks/update` accepts input responses only for outstanding task input requests.
It is the protocol lifecycle endpoint, not an arbitrary task-editing API. The
current background transfer entry does not itself request interactive input.
- `list_tasks` is an MCP tool returning task metadata without result bodies. It
accepts optional `cursor` and `limit` (1–100; default 50) and returns `nextCursor`
when another page is available.
Task handles advertise `pollIntervalMs: 1000` and `ttlMs: null`. Results survive
restart and are not automatically expired or deleted. Restart marks unfinished
work failed with an explicit **unknown remote outcome** message; it never
replays the operation. This persists transfer status/results, not resumable
upload or download execution. There are at most four concurrent tasks and a
16 MiB limit on each persisted task record.
Storage uses an exclusive runtime lease, private directories/files (0700/0600 on
POSIX) and validated records. Corruption, permission errors and write failures
remain errors. The directory is bound to a digest of the effective mode, endpoint
and token; changing that configuration rejects reuse of the directory. Choose a
new private directory for the new configuration and retain the old history.
Credentials are not stored in the scope record, but task results can contain
private account data and should be protected accordingly.
## Message preview panel
`preview_message` is available in every mode and only constructs a draft:
```json
{
"message_type": "group",
"target_id": 20001,
"text": "Hello",
"reply_to": -123456
}
```
Use `private` for a private conversation. `target_id` must be a positive safe
integer; optional `reply_to` is a non-zero signed 32-bit message ID. The preview
supports literal text and optional quoted replies, not media rendering. Text
that resembles message control codes remains literal text.
Every client receives the same readable text draft, `can_send` flag and
corresponding `invoke_action` arguments. Only clients advertising the MCP Apps
extension `io.modelcontextprotocol/ui` with MIME type
`text/html;profile=mcp-app` receive the tool's UI metadata and listed UI resource.
The panel is a bundled, self-contained HTML asset built with the official Apps
client. It loads no external scripts, fonts or media, opens no network connection
of its own and displays external text without interpreting it as HTML. A missing
packaged asset is a visible packaging error.
Loading the panel sends nothing. Its send button calls the existing
`invoke_action` through the host only after the user clicks, and the server's
write gate still applies. Read/docs mode or a host without server-tool support
keeps sending disabled. The panel prevents duplicate clicks and does not retry
failed or uncertain sends; create a new draft only after checking the outcome.
Successful acknowledgement is not proof of delivery or rendering in QQ.
## Action result schemas and examples
`get_action` and `snowluma://onebot/actions` include `returnsSchema` and
`examples` for every bundled action, including aliases. Both are authored beside
the OneBot action definition and copied into the generated catalog. List/search
responses remain compact; use `get_action` to retrieve the complete contract.
`returnsSchema` describes successful OneBot **`data`**, not the full OneBot
envelope or the MCP `structuredContent` wrapper. Check `status` and `retcode`
before reading it. For stream actions, the schema describes each successful
frame's `data`; MCP downloads instead return saved-file metadata and generic
streams return a frame collection. Error frames and failed envelopes are not
successful results. Unsupported actions use `{ "not": {} }` because they have no
successful result. Compatibility placeholders explicitly document their limits.
Each example has `summary`, `params`, and optional `notes` explaining prerequisites
and identifier sources. These are illustrative calls, never run by catalog tools.
Replace example account/group IDs, opaque handles and paths with values from the
current account before execution. Notes identify operations with multiple steps,
such as completing an upload after all chunks have been acknowledged.
For example, `get_action({ "name": "get_msg" })` documents signed message IDs:
```json
{
"action": "get_msg",
"params": { "message_id": -123456 }
}
```
Pass this object to `query_action` with a real message ID. Writes and streams use
`invoke_action` in write mode. For automatic upload, pass
`{ "action": "upload_file_stream", "execution": { "input_file": "/allowed/hello.txt" } }`
to `invoke_action`, with a real file inside the configured upload root. Ordinary
action paths refer to the SnowLuma server, whereas `execution.input_file` and MCP
download paths refer to the MCP host.
Do not substitute zero for a signed message ID. Resource-only forward uploads
and flash shares have a documented `message_id: 0` sentinel; it cannot be used
for message lookup or recall. Also preserve `null` versus `{}`, optional fields,
and decimal strings such as media `file_size` exactly as documented.
## Structured results
Completed tool results use the advertised `outputSchema` and return
`structuredContent` alongside the existing `content` text block. Modern
`input_required` and background task handles are protocol continuations; inspect
their `resultType` first and obtain the completed result through the relevant
continuation or task lifecycle:
```json
{
"result": { "status": "ok", "retcode": 0, "data": { "message_id": -123 } },
"diagnostics": { "outcome": "success" }
}
```
`result` is the original payload, including arrays such as the category list.
It is `null` for an MCP tool error. `diagnostics.outcome` is `success`, `failed`,
or `unknown`; failures can include `kind`, `message`, and `next_step`.
Ordinary OneBot business failures still return their original envelope without
setting `isError`. Their structured diagnostics indicate failure. An asynchronous
acknowledgement or a response that does not confirm success has outcome `unknown`.
Transport errors and existing tool/stream errors still set `isError: true`.
Error categories describe known causes; unclassified execution errors are not
guessed from message strings. Neither tool execution nor connection checks retry
automatically. Negative message IDs remain valid and are preserved unchanged.
## Search, filters, and pagination
`search_actions` retains its original case-insensitive substring matching and
catalog order unless `match: "keywords"` is requested. Keyword mode combines
space-separated terms with AND, recognizes a small set of common Chinese/English
synonyms, and ranks exact action names or aliases first.
```json
{
"query": "群 禁言",
"match": "keywords",
"read_only": false,
"stream": false,
"limit": 10,
"offset": 0
}
```
Both `list_actions` and `search_actions` accept optional `category`, `read_only`,
`stream`, `limit` (1–200), and `offset` (non-negative integer). Omitting pagination
returns the complete matching list and preserves `{count, actions}`. With `limit`
or `offset`, `count` is the returned page size, `total` is the matching count, and
`next_offset` is the next page offset or `null`. Pagination applies to the bundled
catalog; it does not issue server requests.
## Runtime overview
`get_runtime_info({})` returns:
- The effective `mode`, `execution_enabled`, and `write_enabled`.
- The bundled catalog version and action count. `server_support` remains
`unknown`; a matching version alone does not prove action support.
- In execution mode, effective request timeout, file size and directory quota
limits, concurrent stream limit, download directory, upload root, and whether
automatic upload is enabled. In docs mode, `transport` is `null`.
- `connection_checked: false`. Local configuration is not proof of a live login.
The overview does not expose the endpoint, access token, or authorization headers.
Configured file paths are paths on the MCP host. All stream actions still require
write mode; `upload_enabled` describes the configured file root, not permission
to bypass that gate.
`get_runtime_info({"check_connection": true})` is available only in read/write
mode with a client. It explicitly queries `get_login_info`, `get_status`, and
`get_version_info`, using the ordinary read-only execution gate. Each entry under
`checks` contains its original response and diagnostics. Partial failures remain
visible. Check the returned status data for online state; a successful HTTP
exchange alone does not establish that the account is online.
## Cancellation and progress
MCP cancellation now reaches ordinary HTTP calls as well as stream actions,
including response-body reads. Ordinary calls retain their request timeout;
streams retain separate connection and per-read idle timeouts. A cancelled call
does not automatically retry. Cancellation or timeout can leave a remote write's
outcome unknown: stopping the wait does not undo an operation already received
by SnowLuma.
When a caller includes `_meta.progressToken`, stream execution emits standard
`notifications/progress` notifications with the same token. File downloads report
decoded bytes written; automatic uploads report bytes acknowledged by the server.
Generic streams report accepted frame counts. A total is included only when a
positive file size is known. Empty files and unknown-size downloads omit it.
Notifications are limited to one per 250 ms, plus the final pending update before
a successful response. Values increase, notifications stop on cancellation or
completion, and callers without a progress token receive none. Notification
delivery failures are surfaced. Progress describes transferred data, not final
commit: only a successful tool result confirms file validation and completion.
The existing file containment, quotas, partial-file cleanup and upload-reset
behavior are unchanged. File bytes never appear in progress messages.
+173
View File
@@ -0,0 +1,173 @@
# @snowluma/mcp
An [MCP](https://modelcontextprotocol.io) server for the **SnowLuma OneBot action
catalog**. It runs in two modes from one binary:
- **docs** (default) — read-only: every action's docs, parameters, cross-field
constraints, and a ready-to-use **JSON Schema**, so an LLM can answer "what
params does `set_group_ban` take?" without holding the whole catalog in context.
- **execution** (opt-in) — when pointed at a running OneBot HTTP endpoint, the LLM
can also *call* actions: read-only ones freely, write ones behind a gate.
## Docs only (default)
Add to your MCP client (Claude Desktop, Cline, …) — no endpoint, no execution:
```json
{
"mcpServers": {
"snowluma": { "command": "npx", "args": ["-y", "@snowluma/mcp"] }
}
}
```
### Docs tools
- `list_actions({ category? })` — lightweight index (name / category / summary / aliases / `readOnly` / `stream`).
- `get_action({ name })` — full doc for one action incl. `inputSchema` and `readOnly` (accepts aliases).
- `search_actions({ query })` — fuzzy match over name / summary / aliases.
- `list_categories()` — categories and their action counts.
Also exposes the whole catalog as a resource: `snowluma://onebot/actions`.
## Execution (opt-in)
Point the server at a running SnowLuma instance's **OneBot HTTP endpoint** (the
`httpServer` network adapter) and it gains two execution tools:
- `query_action({ action, params? })` — calls a **read-only** action (e.g. `get_*`,
`can_*`) and returns the full OneBot response. Annotated `readOnlyHint`. Refuses
write actions (points you to `invoke_action`).
- `invoke_action({ action, params?, execution? })` — calls **any known** action, including ones
with side effects (send a message, change a group, …). Annotated `destructiveHint`
+ `openWorldHint`. **Only available in write mode.** For automatic local-file
upload, pass `execution: { input_file: "/path/on/the/mcp/host" }` with
`action: "upload_file_stream"`; this option is never forwarded as OneBot params.
Ordinary actions pass the OneBot envelope through verbatim — a logical failure
(`retcode≠0`) comes back as data with its `wording`. Stream protocol failures are
reported as MCP errors because they mean no complete stream result was committed.
Omitted `params` defaults to an empty object; a supplied non-object value is
rejected before calling OneBot.
### Configuration (env)
| Variable | Meaning |
| --- | --- |
| `SNOWLUMA_MCP_ENDPOINT` | OneBot HTTP endpoint, e.g. `http://127.0.0.1:3000/`. **Absent → docs-only** (execution tools hidden). |
| `SNOWLUMA_MCP_TOKEN` | Access token (sent as `Authorization: Bearer …`), if the endpoint requires one. |
| `SNOWLUMA_MCP_TIMEOUT_MS` | Ordinary-action request timeout; for Stream Actions, connection timeout **and per-read idle timeout** (the idle deadline resets after every body read). Default `30000`. Invalid values fail startup. |
| `SNOWLUMA_MCP_MODE` | `docs` \| `read` \| `write`. Default: `read` when an endpoint is set, else `docs`. Invalid values fail startup. |
| `SNOWLUMA_MCP_STREAM_DIR` | Directory on the MCP host for completed Stream Action downloads. Created and validated at startup. Default: `os.tmpdir()/onebot-mcp-downloads`. |
| `SNOWLUMA_MCP_UPLOAD_ROOT` | Existing directory on the MCP host from which automatic uploads may read. Automatic upload is disabled when absent. |
| `SNOWLUMA_MCP_MAX_STREAM_BYTES` | Per-file download/upload ceiling in bytes. Positive integer up to 4 GiB; default `4294967296`. Invalid values fail server startup. |
**Read mode** — the LLM can query read-only actions, but cannot perform any write:
```json
{
"mcpServers": {
"snowluma": {
"command": "npx",
"args": ["-y", "@snowluma/mcp"],
"env": {
"SNOWLUMA_MCP_ENDPOINT": "http://127.0.0.1:3000/",
"SNOWLUMA_MCP_TOKEN": "your-access-token"
}
}
}
}
```
**Write mode** — also enables `invoke_action` (the bot can send messages, manage
groups, etc.). Enable deliberately:
```json
{
"mcpServers": {
"snowluma": {
"command": "npx",
"args": ["-y", "@snowluma/mcp"],
"env": {
"SNOWLUMA_MCP_ENDPOINT": "http://127.0.0.1:3000/",
"SNOWLUMA_MCP_TOKEN": "your-access-token",
"SNOWLUMA_MCP_MODE": "write",
"SNOWLUMA_MCP_STREAM_DIR": "/var/tmp/onebot-mcp-downloads",
"SNOWLUMA_MCP_UPLOAD_ROOT": "/home/me/mcp-share"
}
}
}
}
```
### Stream Actions
The existing `query_action` / `invoke_action` tools automatically select the
ordinary or Stream Action HTTP transport from the generated catalog. No separate
MCP tool is needed.
- File downloads (`download_file_stream`, `download_file_image_stream`, and
`download_file_record_stream`) are parsed frame-by-frame. Base64 chunks are
decoded directly into a randomly named `.part` file under the controlled
download directory, hashed incrementally, and atomically renamed only after a
valid `file_complete` terminal frame. The MCP result contains only
`file_path`, `file_size`, `sha256`, `frame_count`, and `terminal`; the file path
is on the **MCP host**.
- A client admits at most four top-level active Stream Actions. Download files
share a directory quota of `2 * SNOWLUMA_MCP_MAX_STREAM_BYTES`; an unknown-size
download reserves the full per-file limit while active. Successfully committed
files are **not automatically deleted** by MCP and continue consuming quota;
the operator must move or delete them when no longer needed.
- Non-file streams such as `test_download_stream` return a bounded frame summary.
Frame count, summary text, individual wire-frame size, total file bytes, and
file chunk count are capped. A malformed frame, disconnect, timeout, size
violation, caller cancellation, idle timeout, or invalid terminal cancels the
response and removes the partial file. `file_chunk` / `file_complete` frames
are never admitted without a preceding `file_info` header.
- Automatic upload reads `execution.input_file` only after resolving it inside
`SNOWLUMA_MCP_UPLOAD_ROOT` (symlink escapes are rejected), hashes and sends it
in fixed-size chunks, and asks SnowLuma to verify the final SHA-256. Transfer
fields such as `stream_id`, `chunk_data`, and `chunk_index` are MCP-owned and
must not also be supplied in `params`. The returned `file_path` is on the
**SnowLuma/OneBot host** and can be passed to a normal send-file action.
- Automatic upload reset is best-effort **before commit**. If SnowLuma commits
the file but the `file_complete` response is lost, the upload state has already
gone and reset cannot remove that committed file. Keep `file_retention`
non-zero (the default is `300000` ms) so SnowLuma eventually reclaims it.
- Stream Actions remain classified as write operations by the current catalog,
so they require `SNOWLUMA_MCP_MODE=write` and `invoke_action`.
Example automatic upload:
```json
{
"action": "upload_file_stream",
"params": { "file_retention": 300000 },
"execution": { "input_file": "/home/me/mcp-share/report.pdf" }
}
```
### Safety model
- **Read/write is classified per action** in the source specs (by what the action
actually does, not its name) and baked into the catalog. The default is *write*:
an action is callable via `query_action` **only** if it is explicitly read-only.
- **The mode gate is enforced on every call**, not just by hiding tools — calling
`invoke_action` outside write mode is refused even if a client sends it directly.
- **Unknown actions are rejected** by both tools (only catalog actions are callable),
so typos and non-catalog internal actions can't be driven.
- Stream downloads never put file Base64 into the model result. Downloads use a
`.part` + atomic-rename commit; automatic uploads require an explicit realpath-
fenced root and attempt a pre-commit remote reset if transfer fails.
- A well-behaved client can auto-approve `query_action` (read-only) and prompt for
`invoke_action` (destructive) using the MCP tool annotations.
## How it stays in sync
The catalog — including each action's `readOnly` flag — is a **build-time snapshot**
generated from `@snowluma/onebot`'s live action specs (`collectActionDocs()`) on
every build, so it auto-tracks action add/remove and read/write reclassification.
The snapshot is pinned to the SnowLuma version it was built from; a new SnowLuma
release republishes a fresh catalog.
This package is generated; do not hand-edit `src/generated/catalog.ts`.
+9
View File
@@ -0,0 +1,9 @@
import { App } from '@modelcontextprotocol/ext-apps';
import { mountMessagePreview } from './preview-view.js';
const app = new App(
{ name: 'SnowLuma 消息预览', version: '1.0.0' },
{},
{ strict: true, autoResize: true },
);
mountMessagePreview(app, document);
+196
View File
@@ -0,0 +1,196 @@
import type { App } from '@modelcontextprotocol/ext-apps';
export type PreviewApp = Pick<
App,
| 'ontoolresult'
| 'ontoolcancelled'
| 'onerror'
| 'onclose'
| 'connect'
| 'getHostCapabilities'
| 'callServerTool'
>;
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
interface Draft {
message_type: 'group' | 'private';
target_id: number;
text: string;
reply_to?: number;
}
function parseDraft(value: unknown): Draft | undefined {
if (
!record(value) ||
(value.message_type !== 'group' && value.message_type !== 'private') ||
typeof value.target_id !== 'number' ||
!Number.isSafeInteger(value.target_id) ||
value.target_id <= 0 ||
typeof value.text !== 'string' ||
value.text.trim().length === 0 ||
(value.reply_to !== undefined &&
(typeof value.reply_to !== 'number' ||
!Number.isInteger(value.reply_to) ||
value.reply_to === 0 ||
value.reply_to < -2147483648 ||
value.reply_to > 2147483647))
) {
return undefined;
}
return {
message_type: value.message_type,
target_id: value.target_id,
text: value.text,
...(value.reply_to !== undefined ? { reply_to: value.reply_to } : {}),
};
}
export function mountMessagePreview(app: PreviewApp, document: Document): void {
const element = (id: string) => {
const value = document.getElementById(id);
if (!value) {
throw new Error(`Missing preview element: ${id}`);
}
return value;
};
const target = element('target');
const body = element('message');
const reply = element('reply');
const status = element('status');
const send = element('send') as HTMLButtonElement;
const cancel = element('cancel') as HTMLButtonElement;
let draft: Draft | undefined;
let canSend = false;
let connected = false;
let attempted = false;
let cancelled = false;
const update = () => {
send.disabled = !connected || !canSend || !draft || attempted || cancelled;
cancel.disabled = !draft || attempted || cancelled;
};
const stop = (message: string) => {
connected = false;
status.textContent = message;
update();
};
app.ontoolresult = (result) => {
// A host may repeat delivery on restoration; never re-arm a sent draft.
if (attempted || cancelled) {
return;
}
const data = record(result.structuredContent) ? result.structuredContent.result : undefined;
draft = !result.isError && record(data) ? parseDraft(data.draft) : undefined;
canSend = record(data) && data.can_send === true;
if (!draft) {
target.textContent = '未收到有效草稿';
body.textContent = '';
reply.textContent = '';
status.textContent = '草稿无效,请重新创建。';
update();
return;
}
target.textContent = `${draft.message_type === 'group' ? '群聊' : '私聊'} · ${draft.target_id}`;
body.textContent = draft.text;
reply.textContent = draft.reply_to === undefined ? '' : `引用消息:${draft.reply_to}`;
status.textContent = canSend
? '消息尚未发送,请核对收件人和正文。'
: '当前连接仅允许预览。';
update();
};
app.ontoolcancelled = () => {
cancelled = true;
status.textContent = '此草稿已取消。';
update();
};
app.onerror = () => stop('连接出现错误。请重新打开预览;发送状态需以会话为准。');
app.onclose = () => stop('连接已断开。请重新打开预览;发送状态需以会话为准。');
cancel.addEventListener('click', () => {
if (attempted) {
return;
}
cancelled = true;
status.textContent = '此草稿已取消,未发送消息。';
update();
});
send.addEventListener('click', async () => {
if (!connected || !canSend || !draft || attempted || cancelled) {
return;
}
attempted = true;
update();
status.textContent = '正在发送…';
const message: Array<{ type: string; data: Record<string, string> }> = [];
if (draft.reply_to !== undefined) {
message.push({ type: 'reply', data: { id: String(draft.reply_to) } });
}
message.push({ type: 'text', data: { text: draft.text } });
try {
const result = await app.callServerTool({
name: 'invoke_action',
arguments: {
action: draft.message_type === 'group' ? 'send_group_msg' : 'send_private_msg',
params: {
[draft.message_type === 'group' ? 'group_id' : 'user_id']: draft.target_id,
message,
},
},
});
const structured = result.structuredContent;
const envelope = record(structured) ? structured.result : undefined;
const diagnostics = record(structured) ? structured.diagnostics : undefined;
if (
!result.isError &&
record(envelope) &&
envelope.status === 'ok' &&
envelope.retcode === 0
) {
const data = envelope.data;
const messageId = record(data) ? data.message_id : undefined;
status.textContent =
typeof messageId === 'number' && Number.isInteger(messageId)
? `服务端已接受消息。消息编号:${messageId}`
: '服务端已接受消息。';
} else if (record(diagnostics) && diagnostics.outcome === 'failed') {
status.textContent = `发送失败:${typeof diagnostics.message === 'string' ? diagnostics.message : '服务端拒绝了请求。'} 如需重试,请创建新草稿。`;
} else {
status.textContent = '发送状态尚未确认。请先检查会话,不要重复发送。';
}
} catch {
status.textContent = '发送状态尚未确认。请先检查会话,不要重复发送。';
}
});
update();
void app.connect().then(
() => {
connected = app.getHostCapabilities()?.serverTools !== undefined;
if (!connected) {
status.textContent = '当前客户端仅支持预览,不能从面板发送。';
}
update();
},
() => stop('无法连接到消息服务。请重新打开预览。'),
);
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM"],
"noEmit": true,
"types": []
},
"include": ["*.ts"]
}
+65
View File
@@ -0,0 +1,65 @@
{
"license": "SEE LICENSE IN LICENSE",
"name": "@snowluma/mcp",
"version": "2.4.2",
"description": "MCP server exposing the SnowLuma OneBot action catalog (docs + JSON Schemas) and optional execution to LLM clients.",
"repository": {
"type": "git",
"url": "git+https://github.com/SnowLuma/SnowLuma.git",
"directory": "packages/mcp"
},
"homepage": "https://github.com/SnowLuma/SnowLuma#readme",
"bugs": {
"url": "https://github.com/SnowLuma/SnowLuma/issues"
},
"type": "module",
"bin": {
"snowluma-mcp": "./dist/server.js"
},
"main": "./dist/server.js",
"types": "./dist/server.d.ts",
"files": [
"dist",
"README.md",
"AGENT_FEATURES.md"
],
"scripts": {
"clean": "node -e \"const fs=require('node:fs');fs.rmSync('dist',{recursive:true,force:true})\"",
"gen": "vitest run scripts/gen-catalog.test.ts",
"prebuild": "pnpm run clean && pnpm run gen",
"build": "tsc -p tsconfig.json && node scripts/build-message-app.mjs",
"prepack": "pnpm run build",
"typecheck": "tsc --noEmit && tsc -p apps/tsconfig.json",
"test": "vitest run",
"start": "node dist/server.js"
},
"keywords": [
"mcp",
"model-context-protocol",
"onebot",
"snowluma",
"qq"
],
"dependencies": {
"@modelcontextprotocol/server": "^2.3.1",
"ws": "^8.21.0"
},
"devDependencies": {
"@modelcontextprotocol/client": "^2.3.1",
"@modelcontextprotocol/sdk": "^1.32.0",
"@snowluma/onebot": "workspace:*",
"@snowluma/proton": "workspace:*",
"@types/node": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "4.1.12",
"@types/ws": "^8.18.1",
"@modelcontextprotocol/ext-apps": "^2.0.3"
},
"engines": {
"node": "^24.22.1 || >=26.12.1"
},
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,48 @@
import { createHash } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'vite';
const packageRoot = fileURLToPath(new URL('../', import.meta.url));
const style = `
:root{color-scheme:light dark;font-family:system-ui,sans-serif;color:var(--color-text-primary,#20242d);background:var(--color-background-primary,#fff)}
*{box-sizing:border-box}body{margin:0;padding:24px;max-width:760px}h1{font-size:21px;margin:0 0 18px}#target{font-size:14px;font-weight:650;margin:0 0 14px}#message{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;line-height:1.6;margin:0;padding:18px;border:1px solid var(--color-border-primary,#d7dce3);border-radius:10px}#reply{font-size:13px;margin:12px 0;color:var(--color-text-secondary,#626b78)}#status{font-size:14px;line-height:1.5;margin:18px 0}footer{display:flex;gap:10px;flex-wrap:wrap}button{font:inherit;font-size:14px;border:1px solid var(--color-border-primary,#bbc4d0);border-radius:8px;padding:10px 16px;cursor:pointer;background:var(--color-background-primary,#fff);color:inherit}#send{background:#2252a5;color:white;border-color:#2252a5}button:disabled{opacity:.55;cursor:default}button:focus-visible{outline:3px solid #8eb7ff;outline-offset:3px}@media(prefers-color-scheme:dark){:root{color:var(--color-text-primary,#e8ecf3);background:var(--color-background-primary,#1b1e24)}#reply{color:var(--color-text-secondary,#b3bdcb)}button{background:var(--color-background-primary,#1b1e24)}}
`.trim();
export async function buildMessageApp(outDir = join(packageRoot, 'dist')) {
const result = await build({
configFile: false,
root: packageRoot,
logLevel: 'error',
build: {
write: false,
sourcemap: false,
lib: {
entry: join(packageRoot, 'apps/message-preview.ts'),
name: 'SnowLumaMessagePreview',
formats: ['iife'],
},
},
});
const outputs = Array.isArray(result) ? result.flatMap((item) => item.output) : result.output;
if (outputs.length !== 1 || outputs[0].type !== 'chunk') {
throw new Error('Message app must build to a single self-contained script');
}
const script = outputs[0].code.replace(/<\/script/gi, '<\\/script');
const hash = (source) => createHash('sha256').update(source).digest('base64');
const csp = `default-src 'none'; script-src 'sha256-${hash(script)}'; style-src 'sha256-${hash(style)}'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'`;
const html = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${csp}"><title>消息草稿预览</title><style>${style}</style></head>
<body><main><h1>消息草稿预览</h1><p id="target">正在载入草稿…</p><pre id="message"></pre><p id="reply"></p><p id="status" role="status" aria-live="polite">消息尚未发送。</p><footer><button id="send" type="button" disabled>发送这条消息</button><button id="cancel" type="button" disabled>取消草稿</button></footer></main><script>${script}</script></body></html>`;
const filename = join(outDir, 'message-app.html');
await mkdir(dirname(filename), { recursive: true });
await writeFile(filename, html, 'utf8');
return filename;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await buildMessageApp();
}
+42
View File
@@ -0,0 +1,42 @@
// Catalog generator. Runs under vitest (so @snowluma/onebot/action-docs resolves
// via the proton plugin, exactly as onebot's own tests do) and writes a build-time
// snapshot of the OneBot action catalog into src/generated/catalog.ts.
//
// This is the whole "auto-track Action 增减" mechanic: the snapshot is regenerated
// from the live specs on every build, so it can never describe a stale action set.
import { mkdirSync, writeFileSync } from 'node:fs';
import { it, expect } from 'vitest';
import { collectActionDocs, collectCategories } from '@snowluma/onebot/action-docs';
it('generates src/generated/catalog.ts from the live action specs', () => {
const actions = collectActionDocs();
const categories = collectCategories();
expect(actions.length).toBeGreaterThan(100);
// every action must carry a composed input schema (the MCP's payoff)
for (const a of actions) {
expect(a.inputSchema).toBeTypeOf('object');
expect(a.name).toBeTruthy();
expect(a.returnsSchema, `${a.name} result schema`).toBeTypeOf('object');
expect(a.examples?.length, `${a.name} invocation examples`).toBeGreaterThan(0);
}
const body =
'// GENERATED by scripts/gen-catalog.test.ts — do not edit. Run `pnpm gen`.\n' +
'/* eslint-disable */\n' +
"import type { CatalogAction, CatalogCategory } from '../types.js';\n\n" +
`export const ACTIONS: CatalogAction[] = ${JSON.stringify(actions, null, 2)};\n\n` +
`export const CATEGORIES: CatalogCategory[] = ${JSON.stringify(categories, null, 2)};\n`;
const dir = new URL('../src/generated/', import.meta.url);
mkdirSync(dir, { recursive: true });
writeFileSync(new URL('../src/generated/catalog.ts', import.meta.url), body);
// Plain-JSON twin of the catalog, consumed by the docs site (SnowLumaDocs)
// via the sync-docs-catalog workflow. Same data as catalog.ts.
writeFileSync(
new URL('../src/generated/catalog.json', import.meta.url),
JSON.stringify({ actions, categories }, null, 2) + '\n',
);
});
File diff suppressed because it is too large. Load diff
+119
View File
@@ -0,0 +1,119 @@
import type { CatalogAction } from './types.js';
export const FILTER_PROPERTIES = {
category: { type: 'string', description: '按分类过滤,如 群管理 / 消息 / 好友' },
read_only: { type: 'boolean', description: '只返回指定读写类型的动作' },
stream: { type: 'boolean', description: '只返回指定流式类型的动作' },
limit: {
type: 'integer',
minimum: 1,
maximum: 200,
description: '每页数量;省略时返回全部匹配项',
},
offset: { type: 'integer', minimum: 0, description: '跳过的匹配项数量,默认 0' },
};
const SYNONYMS = [
['历史消息', '消息历史', 'history'],
['群聊', '群', 'group'],
['私聊', '好友', 'private', 'friend'],
['禁言', 'ban', 'mute'],
['撤回', '撤销消息', 'delete_msg', 'recall'],
['图片', 'image', 'photo'],
['语音', 'record', 'voice'],
['文件', 'file'],
['上传', 'upload'],
['下载', 'download'],
];
export function lite(action: CatalogAction) {
return {
name: action.name,
category: action.category,
summary: action.summary,
aliases: action.aliases,
readOnly: action.readOnly,
stream: action.stream ?? false,
};
}
/** Optional controls must not change legacy matching, order or response fields. */
export function discover(actions: CatalogAction[], args: Record<string, unknown>, search: boolean) {
for (const key of ['read_only', 'stream']) {
if (args[key] !== undefined && typeof args[key] !== 'boolean') {
throw new Error(`${key} 必须是布尔值。`);
}
}
for (const key of ['limit', 'offset']) {
const value = args[key];
if (
value !== undefined &&
(typeof value !== 'number' ||
!Number.isSafeInteger(value) ||
value < (key === 'limit' ? 1 : 0) ||
(key === 'limit' && value > 200))
) {
throw new Error(`${key} 必须是有效整数(limit 为 1–200,offset 不小于 0)。`);
}
}
if (args.match !== undefined && args.match !== 'substring' && args.match !== 'keywords') {
throw new Error('match 必须为 substring 或 keywords。');
}
const query = (typeof args.query === 'string' ? args.query : '').toLowerCase();
const category = typeof args.category === 'string' ? args.category : undefined;
const keywords = query.trim().split(/\s+/u).filter(Boolean);
let list = actions.filter((action) => {
if (
(category && action.category !== category) ||
(args.read_only !== undefined && action.readOnly !== args.read_only) ||
(args.stream !== undefined && (action.stream ?? false) !== args.stream)
) {
return false;
}
if (!search) {
return true;
}
const fields = [action.name, action.summary ?? '', ...action.aliases].map((s) =>
s.toLowerCase(),
);
if (args.match !== 'keywords') {
return fields.some((field) => field.includes(query));
}
return keywords.every((keyword) => {
const alternatives = SYNONYMS.find((group) => group.includes(keyword)) ?? [keyword];
return alternatives.some((alternative) =>
fields.some((field) => field.includes(alternative)),
);
});
});
if (search && args.match === 'keywords') {
const exact = (action: CatalogAction) =>
action.name.toLowerCase() === query.trim() ||
action.aliases.some((alias) => alias.toLowerCase() === query.trim());
list = list.sort((a, b) => Number(exact(b)) - Number(exact(a)));
}
const offset = typeof args.offset === 'number' ? args.offset : 0;
const limit = typeof args.limit === 'number' ? args.limit : list.length;
const page = list.slice(offset, offset + limit).map(lite);
const paginated = args.limit !== undefined || args.offset !== undefined;
return {
count: page.length,
actions: page,
...(paginated
? {
total: list.length,
next_offset: offset + page.length < list.length ? offset + page.length : null,
}
: {}),
};
}
+344
View File
@@ -0,0 +1,344 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import {
createRequestStateCodec,
fromJsonSchema,
inputRequired,
inputResponse,
type InputRequiredResult,
type ServerContext,
type ServerOptions,
type Tool,
} from '@modelcontextprotocol/server';
import { asError, type StructuredResult } from './results.js';
const INPUT_KEY = 'missing_parameters';
const INPUT_NAMES = {
query_action_with_input: 'query_action',
invoke_action_with_input: 'invoke_action',
} as const;
export type InputCompletionTool = keyof typeof INPUT_NAMES;
export interface InputPreparation {
action: string;
params: Record<string, unknown>;
inputSchema: Record<string, unknown>;
}
export interface InputCompletionCallbacks {
capabilities: unknown;
/** Runs the same permission and argument checks as the existing execution tools. */
preflight: (args: Record<string, unknown>) => InputPreparation | StructuredResult;
execute: (args: Record<string, unknown>) => Promise<StructuredResult>;
}
interface PendingInput {
fingerprint: string;
expiresAt: number;
}
interface InputState {
challenge: string;
}
export interface InputCompletion {
verifyRequestState: NonNullable<NonNullable<ServerOptions['requestState']>['verify']>;
handle: (
name: InputCompletionTool,
args: Record<string, unknown>,
context: ServerContext,
callbacks: InputCompletionCallbacks,
) => Promise<StructuredResult | InputRequiredResult>;
close: () => void;
}
export function isInputCompletionTool(name: string): name is InputCompletionTool {
return Object.hasOwn(INPUT_NAMES, name);
}
export function inputCompletionBaseTool(
name: InputCompletionTool,
): 'query_action' | 'invoke_action' {
return INPUT_NAMES[name];
}
/** Derive visibility, schemas, annotations and result contracts from the existing tools. */
export function inputCompletionTools(baseTools: Tool[]): Tool[] {
return baseTools.flatMap((tool) => {
if (tool.name !== 'query_action' && tool.name !== 'invoke_action') {
return [];
}
return [
{
...tool,
name: `${tool.name}_with_input`,
description: `${tool.description} 缺少必填参数时向支持表单的客户端请求补充;补充完成前不执行动作,已有参数不会被覆盖。`,
},
];
});
}
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
// Bind every round to the exact tool and original arguments, irrespective of JSON
// object key ordering. Values never appear in the signed token or diagnostics.
function fingerprint(name: string, args: Record<string, unknown>): string {
function ordered(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(ordered);
}
if (isObject(value)) {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, ordered(value[key])]),
);
}
return value;
}
return createHash('sha256')
.update(JSON.stringify([name, ordered(args)]))
.digest('hex');
}
function supportsForm(capabilities: unknown): boolean {
const elicitation = isObject(capabilities) ? capabilities.elicitation : undefined;
if (!isObject(elicitation)) {
return false;
}
// The pre-form/url capability shape declared form support with an empty object.
return (
isObject(elicitation.form) ||
(elicitation.form === undefined && elicitation.url === undefined)
);
}
/**
* One instance belongs to one configured runtime, shared by its server factory.
* Pending prompts are deliberately transient: a restart invalidates unfinished
* input, and never resumes or retries a side effect automatically.
*/
export function createInputCompletion(
options: { ttlSeconds?: number; maxPending?: number } = {},
): InputCompletion {
const ttlSeconds = options.ttlSeconds ?? 600;
const maxPending = options.maxPending ?? 256;
if (
!Number.isInteger(ttlSeconds) ||
ttlSeconds < 1 ||
!Number.isInteger(maxPending) ||
maxPending < 1
) {
throw new RangeError('Input completion limits must be positive integers');
}
const codec = createRequestStateCodec<InputState>({
key: randomBytes(32),
ttlSeconds,
// HTTP callers must supply validated authInfo. Stdio has one trusted peer.
// The codec hides the binding behind an HMAC, including bearer credentials.
bind: (context) =>
JSON.stringify([
context.mcpReq.method,
context.http?.authInfo?.clientId ?? null,
context.http?.authInfo?.token ?? null,
context.sessionId ?? null,
]),
});
const pending = new Map<string, PendingInput>();
let closed = false;
return {
verifyRequestState: async (token, context) => {
if (closed) {
throw new Error('closed');
}
return codec.verify(token, context);
},
close() {
closed = true;
pending.clear();
},
async handle(name, args, context, callbacks) {
if (closed) {
return asError('补充输入服务已关闭。', 'configuration');
}
context.mcpReq.signal.throwIfAborted();
const prepared = callbacks.preflight(args);
if ('structuredContent' in prepared) {
return prepared;
}
const state = context.mcpReq.requestState<unknown>();
const required = Array.isArray(prepared.inputSchema.required)
? prepared.inputSchema.required.filter(
(key): key is string => typeof key === 'string',
)
: [];
const missing = required.filter((key) => !Object.hasOwn(prepared.params, key));
const original = fingerprint(name, args);
for (const [id, entry] of pending) {
if (entry.expiresAt <= Date.now()) {
pending.delete(id);
}
}
if (state !== undefined) {
if (!isObject(state) || typeof state.challenge !== 'string') {
return asError('补充输入状态无效。');
}
const entry = pending.get(state.challenge);
if (!entry || entry.fingerprint !== original) {
return asError('补充输入已过期、已使用或原始调用已变更,请重新发起调用。');
}
// Claim synchronously before validation or execution can yield. A
// replay/concurrent retry can never execute the same answer twice.
pending.delete(state.challenge);
const response = inputResponse(context.mcpReq.inputResponses, INPUT_KEY);
if (response.kind === 'elicit' && response.action !== 'accept') {
return asError(
'用户拒绝或取消补充输入,动作未执行。',
'cancelled',
'按用户意愿停止;需要时重新发起调用。',
);
}
if (
response.kind !== 'elicit' ||
!isObject(response.content) ||
typeof response.content.params_json !== 'string' ||
Object.keys(response.content).some((key) => key !== 'params_json')
) {
return asError('补充输入必须包含 params_json 字符串,动作未执行。');
}
let additions: unknown;
try {
additions = JSON.parse(response.content.params_json);
} catch {
return asError('补充参数不是有效 JSON,动作未执行。');
}
if (
!isObject(additions) ||
Object.keys(additions).some((key) => !missing.includes(key))
) {
return asError(
'补充参数只能包含本次缺失的必填字段,不能覆盖已有参数或添加其他字段。',
);
}
const properties = isObject(prepared.inputSchema.properties)
? prepared.inputSchema.properties
: {};
const schema = fromJsonSchema<Record<string, unknown>>({
type: 'object',
properties: Object.fromEntries(
missing.map((key) => [key, properties[key] ?? {}]),
),
required: missing,
additionalProperties: false,
});
const validated = await schema['~standard'].validate(additions);
if (validated.issues) {
return asError(
'补充参数不符合动作目录中的字段类型或约束,动作未执行。',
'invalid_arguments',
'使用 get_action 查看参数要求后重新发起调用。',
);
}
context.mcpReq.signal.throwIfAborted();
if (closed) {
return asError('补充输入服务已关闭,动作未执行。', 'configuration');
}
return callbacks.execute({
...args,
params: { ...prepared.params, ...validated.value },
});
}
if (context.mcpReq.inputResponses || context.mcpReq.droppedInputResponseKeys?.length) {
return asError('补充输入缺少有效请求状态,动作未执行。');
}
if (!missing.length) {
return callbacks.execute(args);
}
if (!supportsForm(callbacks.capabilities)) {
return asError(
'当前客户端未声明表单补充输入能力;请直接提供必填参数。',
'configuration',
'使用 get_action 查看参数后调用原 query_action 或 invoke_action。',
);
}
if (pending.size >= maxPending) {
return asError(
'待补充输入的请求数量已达上限,请完成现有请求或等待过期。',
'execution',
);
}
const challenge = randomUUID();
pending.set(challenge, {
fingerprint: original,
expiresAt: Date.now() + ttlSeconds * 1000,
});
try {
const requestState = await codec.mint({ challenge }, context);
context.mcpReq.signal.throwIfAborted();
if (closed) {
throw new Error('Input completion closed');
}
const properties = isObject(prepared.inputSchema.properties)
? prepared.inputSchema.properties
: {};
return inputRequired({
inputRequests: {
[INPUT_KEY]: inputRequired.elicit({
mode: 'form',
message: `请补充 ${prepared.action} 缺失的必填参数:${missing.join('、')}。仅填写这些字段的 JSON 对象,已有参数保持不变。字段要求:${JSON.stringify(Object.fromEntries(missing.map((key) => [key, properties[key] ?? {}])))}`,
requestedSchema: {
type: 'object',
properties: {
params_json: { type: 'string', title: '缺失参数(JSON 对象)' },
},
required: ['params_json'],
},
}),
},
requestState,
});
} catch (error) {
pending.delete(challenge);
throw error;
}
},
};
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+157
View File
@@ -0,0 +1,157 @@
import { readFile } from 'node:fs/promises';
import type { ReadResourceResult, Resource, Tool } from '@modelcontextprotocol/server';
import { asError, asText, RESULT_SCHEMA, type StructuredResult } from './results.js';
export const MESSAGE_PREVIEW_TOOL = 'preview_message';
export const MESSAGE_APP_URI = 'ui://snowluma/message-preview.html';
export const MESSAGE_APP_MIME_TYPE = 'text/html;profile=mcp-app';
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function supportsMessageApp(capabilities: unknown): boolean {
if (!record(capabilities) || !record(capabilities.extensions)) {
return false;
}
const ui = capabilities.extensions['io.modelcontextprotocol/ui'];
return (
record(ui) && Array.isArray(ui.mimeTypes) && ui.mimeTypes.includes(MESSAGE_APP_MIME_TYPE)
);
}
export function messagePreviewTool(uiSupported: boolean): Tool {
return {
name: MESSAGE_PREVIEW_TOOL,
description:
'预览一条纯文本群聊或私聊草稿,可附带引用消息编号。只生成草稿,不发送消息;支持面板的客户端可由用户点击发送,仍受写模式限制。文字按字面发送,不解释消息控制代码。',
inputSchema: {
type: 'object',
properties: {
message_type: { type: 'string', enum: ['group', 'private'] },
target_id: { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER },
text: { type: 'string', minLength: 1 },
reply_to: {
type: 'integer',
minimum: -2147483648,
maximum: 2147483647,
not: { const: 0 },
description: '可选引用消息编号,负值合法。',
},
},
required: ['message_type', 'target_id', 'text'],
additionalProperties: false,
},
outputSchema: RESULT_SCHEMA,
annotations: { readOnlyHint: true, openWorldHint: false },
...(uiSupported
? { _meta: { ui: { resourceUri: MESSAGE_APP_URI, visibility: ['model', 'app'] } } }
: {}),
};
}
/** Pure draft construction. Sending remains an ordinary invoke_action call. */
export function previewMessage(args: Record<string, unknown>, canSend: boolean): StructuredResult {
if (
Object.keys(args).some(
(key) => !['message_type', 'target_id', 'text', 'reply_to'].includes(key),
)
) {
return asError('草稿包含不支持的字段。');
}
const { message_type: messageType, target_id: targetId, text, reply_to: replyTo } = args;
if (messageType !== 'group' && messageType !== 'private') {
return asError('message_type 必须为 group 或 private。');
}
if (typeof targetId !== 'number' || !Number.isSafeInteger(targetId) || targetId <= 0) {
return asError('target_id 必须是正的安全整数。');
}
if (typeof text !== 'string' || text.trim().length === 0) {
return asError('text 必须是非空文本。');
}
if (
replyTo !== undefined &&
(typeof replyTo !== 'number' ||
!Number.isInteger(replyTo) ||
replyTo === 0 ||
replyTo < -2147483648 ||
replyTo > 2147483647)
) {
return asError('reply_to 必须是非零的有符号 32 位消息编号,负值合法。');
}
const message: Array<{ type: string; data: Record<string, string> }> = [];
if (replyTo !== undefined) {
message.push({ type: 'reply', data: { id: String(replyTo) } });
}
// Explicit text segments preserve literal control-code-looking text.
message.push({ type: 'text', data: { text } });
return asText({
draft: {
message_type: messageType,
target_id: targetId,
text,
...(replyTo !== undefined ? { reply_to: replyTo } : {}),
},
can_send: canSend,
notice: '此为待发送草稿,尚未发送。',
invocation: {
name: 'invoke_action',
arguments: {
action: messageType === 'group' ? 'send_group_msg' : 'send_private_msg',
params: { [messageType === 'group' ? 'group_id' : 'user_id']: targetId, message },
},
},
});
}
export function messageAppResources(uiSupported: boolean): Resource[] {
return uiSupported
? [
{
uri: MESSAGE_APP_URI,
name: '消息草稿预览',
description: '查看消息草稿并按需手动发送。',
mimeType: MESSAGE_APP_MIME_TYPE,
},
]
: [];
}
export async function readMessageAppResource(uri: string): Promise<ReadResourceResult | undefined> {
if (uri !== MESSAGE_APP_URI) {
return undefined;
}
// The build emits the same self-contained asset beside this compiled module.
// A missing asset is a packaging error and must not become a blank panel.
const text = await readFile(new URL('./message-app.html', import.meta.url), 'utf8');
return {
contents: [
{
uri,
mimeType: MESSAGE_APP_MIME_TYPE,
text,
_meta: {
ui: {
csp: {
connectDomains: [],
resourceDomains: [],
frameDomains: [],
baseUriDomains: [],
},
prefersBorder: true,
},
},
},
],
};
}
+44
View File
@@ -0,0 +1,44 @@
import type { TransferProgress } from './client.js';
/** One reporter per request. No timers or notifications survive the request. */
export function createProgressReporter(
send: (progress: TransferProgress) => Promise<void>,
signal: AbortSignal,
now: () => number = Date.now,
) {
let latest: TransferProgress | undefined;
let sent = -1;
let sentAt = -Infinity;
const flush = async (): Promise<void> => {
if (!latest || latest.progress <= sent || signal.aborted) {
return;
}
await send(latest);
sent = latest.progress;
sentAt = now();
};
return {
async report(progress: TransferProgress): Promise<void> {
if (
!Number.isFinite(progress.progress) ||
progress.progress < (latest?.progress ?? 0) ||
(progress.total !== undefined &&
(!Number.isFinite(progress.total) ||
progress.total <= 0 ||
progress.progress > progress.total))
) {
throw new Error('Invalid transfer progress');
}
latest = progress;
if (now() - sentAt >= 250) {
await flush();
}
},
finish: flush,
};
}
+142
View File
@@ -0,0 +1,142 @@
import type { CallToolResult, Tool } from '@modelcontextprotocol/server';
import type { OneBotEnvelope } from './client.js';
export type FailureKind =
| 'invalid_arguments'
| 'not_found'
| 'permission'
| 'configuration'
| 'business'
| 'authentication'
| 'cancelled'
| 'timeout'
| 'transport'
| 'invalid_response'
| 'execution';
export interface Diagnostics {
outcome: 'success' | 'failed' | 'unknown';
kind?: FailureKind;
message?: string;
next_step?: string;
}
export type StructuredResult = CallToolResult & {
structuredContent: { result: unknown; diagnostics: Diagnostics };
};
export class ActionCallError extends Error {
constructor(
message: string,
readonly kind: FailureKind,
options?: ErrorOptions,
) {
super(message, options);
this.name = 'ActionCallError';
}
}
export const RESULT_SCHEMA: Tool['outputSchema'] = {
type: 'object',
properties: {
result: {},
diagnostics: {
type: 'object',
properties: {
outcome: { type: 'string', enum: ['success', 'failed', 'unknown'] },
kind: { type: 'string' },
message: { type: 'string' },
next_step: { type: 'string' },
},
required: ['outcome'],
additionalProperties: false,
},
},
required: ['result', 'diagnostics'],
additionalProperties: false,
};
// The text block is the original public contract; structured metadata is additive.
export function asText(
data: unknown,
diagnostics: Diagnostics = { outcome: 'success' },
): StructuredResult {
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
structuredContent: { result: data, diagnostics: { ...diagnostics } },
};
}
export function asError(
message: string,
kind: FailureKind = 'invalid_arguments',
nextStep = '检查工具参数;使用 get_action 查看动作要求。',
outcome: Diagnostics['outcome'] = 'failed',
): StructuredResult {
return {
content: [{ type: 'text', text: message }],
isError: true,
structuredContent: {
result: null,
diagnostics: { outcome, kind, message, next_step: nextStep },
},
};
}
export function envelopeDiagnostics(envelope: OneBotEnvelope): Diagnostics {
if (envelope.status === 'ok' && envelope.retcode === 0) {
return { outcome: 'success' };
}
if (envelope.status === 'async') {
return { outcome: 'unknown', next_step: '请求已受理,尚未确认完成;不要重复执行写操作。' };
}
if (
envelope.status === 'failed' ||
(typeof envelope.retcode === 'number' && envelope.retcode !== 0)
) {
return {
outcome: 'failed',
kind: 'business',
message:
(typeof envelope.wording === 'string' && envelope.wording) ||
(typeof envelope.message === 'string' && envelope.message) ||
`retcode=${envelope.retcode}`,
next_step: '检查原始响应的 retcode、wording 和动作参数;不要盲目重试写操作。',
};
}
return {
outcome: 'unknown',
kind: 'invalid_response',
next_step: '响应未确认成功;检查服务端版本和日志。',
};
}
export function executionError(
action: string,
error: unknown,
signal?: AbortSignal,
): StructuredResult {
const message = `调用失败 (${action}): ${error instanceof Error ? error.message : String(error)}`;
let cause = error;
const seen = new Set<unknown>();
while (cause instanceof Error && !(cause instanceof ActionCallError) && !seen.has(cause)) {
seen.add(cause);
cause = cause.cause;
}
const kind = signal?.aborted
? 'cancelled'
: cause instanceof ActionCallError
? cause.kind
: 'execution';
return asError(
message,
kind,
'操作结果可能未知。检查连接、配置和服务端状态;取消或超时不代表撤销,确认结果后再决定是否重试。',
'unknown',
);
}
+400
View File
@@ -0,0 +1,400 @@
import {
Server,
ProtocolError,
isInputRequiredResult,
isSpecType,
type ServerContext,
type Tool,
} from '@modelcontextprotocol/server';
import { ACTIONS, CATEGORIES } from './generated/catalog.js';
import { callTool, computeTools, prepareActionCall, type CallCtx } from './tools.js';
import { createProgressReporter } from './progress.js';
import { VERSION } from './version.js';
import { createCallTrace } from './tracing.js';
import {
createInputCompletion,
inputCompletionBaseTool,
inputCompletionTools,
isInputCompletionTool,
type InputCompletion,
} from './elicitation.js';
import {
MESSAGE_PREVIEW_TOOL,
messageAppResources,
messagePreviewTool,
previewMessage,
readMessageAppResource,
supportsMessageApp,
} from './message-app.js';
import { EventSubscriptions, EVENT_TOOLS, type EventSubscriptionOptions } from './subscriptions.js';
import {
installTaskHandlers,
supportsTasks,
type PersistentTasks,
type TaskHandle,
} from './tasks.js';
import { asError, asText, RESULT_SCHEMA, type StructuredResult } from './results.js';
export interface McpServerOptions extends Pick<CallCtx, 'mode' | 'client'> {
inputCompletion?: InputCompletion;
tasks?: PersistentTasks;
events?: EventSubscriptionOptions;
}
const RESOURCE_URI = 'snowluma://onebot/actions';
export function createMcpServer(ctx: McpServerOptions): Server {
const input = ctx.inputCompletion ?? createInputCompletion();
const events =
ctx.mode !== 'docs' && ctx.events ? new EventSubscriptions(ctx.events) : undefined;
const legacySubscriptions = new Set<string>();
const server = new Server(
{ name: 'snowluma-mcp', version: VERSION },
{
capabilities: {
tools: {},
resources: events ? { subscribe: true, listChanged: true } : {},
extensions: { 'io.modelcontextprotocol/ui': {} },
},
requestState: { verify: input.verifyRequestState },
// Tool availability depends on the configured mode; only the bundled
// action catalog is public. Execution and account data are never cached.
cacheHints: {
'tools/list': { ttlMs: 300_000, cacheScope: 'private' },
'resources/list': { ttlMs: events ? 0 : 300_000, cacheScope: 'private' },
'resources/read': { ttlMs: 0, cacheScope: 'private' },
'resources/templates/list': { ttlMs: 300_000, cacheScope: 'public' },
},
},
);
const capabilities = (request: ServerContext) => {
const envelope: Record<string, unknown> | undefined = request.mcpReq.envelope;
if (!envelope) {
return server.getClientCapabilities();
}
const value = envelope['io.modelcontextprotocol/clientCapabilities'];
if (!isSpecType.ClientCapabilities(value)) {
throw new ProtocolError(-32602, 'Invalid client capabilities');
}
return value;
};
const cacheMetadata = (scope: 'public' | 'private') =>
(server.getNegotiatedProtocolVersion() ?? '') >= '2026-07-28'
? { ttlMs: scope === 'public' ? 300_000 : 0, cacheScope: scope }
: {};
const taskEnabled = (request: ServerContext) =>
!!ctx.tasks &&
ctx.mode === 'write' &&
(server.getNegotiatedProtocolVersion() ?? '') >= '2026-07-28' &&
supportsTasks(request.mcpReq.envelope);
const availableTools = (request: ServerContext): Tool[] => {
const base = computeTools(ctx.mode);
const extra = [
...inputCompletionTools(base),
messagePreviewTool(supportsMessageApp(capabilities(request))),
...(events ? EVENT_TOOLS : []),
];
if (taskEnabled(request)) {
const invocation = base.find((tool) => tool.name === 'invoke_action')!;
extra.push(
{
...invocation,
name: 'invoke_action_background',
description:
'在后台执行一个流式传输动作,返回可跨重启查询的任务编号。仅限流式动作和写模式;重启不会自动重放未完成操作。',
},
{
name: 'list_tasks',
description: '列出本配置下持久化任务的编号和状态,不返回结果正文。',
inputSchema: {
type: 'object',
properties: {
cursor: { type: 'string' },
limit: { type: 'integer', minimum: 1, maximum: 100 },
},
additionalProperties: false,
},
outputSchema: RESULT_SCHEMA,
annotations: { readOnlyHint: true, openWorldHint: false },
},
);
}
return [...base, ...extra];
};
if (ctx.tasks && ctx.mode === 'write') {
installTaskHandlers(server, ctx.tasks);
}
events?.onUpdate(async (uri) => {
if (
(server.getNegotiatedProtocolVersion() ?? '') >= '2026-07-28' ||
legacySubscriptions.has(uri)
) {
await server.sendResourceUpdated({ uri });
}
});
server.onclose = () => {
events?.close();
legacySubscriptions.clear();
if (!ctx.inputCompletion) {
input.close();
}
};
server.setRequestHandler('tools/list', async (_req, request) => ({
tools: availableTools(request),
}));
server.setRequestHandler('tools/call', async (req, request) => {
const { mcpReq } = request;
const trace = createCallTrace(mcpReq._meta);
const tool = availableTools(request).some((entry) => entry.name === req.params.name)
? req.params.name
: 'unlisted';
const started = performance.now();
const report = (event: string, outcome?: string, kind?: string, taskId?: string) => {
console.error(
'[snowluma-mcp] %s',
JSON.stringify({
event,
tool,
traceparent: trace.traceparent,
...(outcome
? { outcome, duration_ms: Math.round(performance.now() - started) }
: {}),
...(kind ? { kind } : {}),
...(taskId ? { task_id: taskId } : {}),
}),
);
};
report('call_started');
const token = mcpReq._meta?.progressToken;
const progress =
token === undefined
? undefined
: createProgressReporter(
(value) =>
mcpReq.notify({
method: 'notifications/progress',
params: { progressToken: token, ...value },
}),
mcpReq.signal,
);
try {
const args = req.params.arguments ?? {};
const callContext: CallCtx = {
mode: ctx.mode,
client: ctx.client,
signal: mcpReq.signal,
trace,
...(progress ? { onProgress: progress.report } : {}),
};
const dispatch = async (): Promise<
StructuredResult | TaskHandle | Awaited<ReturnType<InputCompletion['handle']>>
> => {
if (isInputCompletionTool(req.params.name)) {
const base = inputCompletionBaseTool(req.params.name);
return input.handle(req.params.name, args, request, {
capabilities: capabilities(request),
preflight: (values) => {
const prepared = prepareActionCall(
values,
callContext,
base === 'query_action' ? 'read' : 'write',
);
return 'structuredContent' in prepared
? prepared
: {
action: prepared.action,
params: prepared.params,
inputSchema: prepared.inputSchema,
};
},
execute: (values) => callTool(base, values, callContext),
});
}
if (req.params.name === MESSAGE_PREVIEW_TOOL) {
return previewMessage(args, ctx.mode === 'write' && !!ctx.client);
}
if (events?.handlesTool(req.params.name)) {
const result = await events.callTool(req.params.name, args, mcpReq.signal);
if (!result.isError) {
if (
req.params.name === 'unsubscribe_events' &&
typeof args.uri === 'string'
) {
legacySubscriptions.delete(args.uri);
}
await server.sendResourceListChanged();
}
return result;
}
if (
req.params.name === 'invoke_action_background' ||
req.params.name === 'list_tasks'
) {
if (!taskEnabled(request) || !ctx.tasks) {
return asError(
'持久任务需配置任务目录、启用写模式,并由客户端声明新版任务能力。',
'configuration',
);
}
if (req.params.name === 'list_tasks') {
if (
Object.keys(args).some((key) => key !== 'cursor' && key !== 'limit') ||
(args.cursor !== undefined && typeof args.cursor !== 'string') ||
(args.limit !== undefined && typeof args.limit !== 'number')
) {
return asError('任务列表参数无效。');
}
return asText(
ctx.tasks.list({
cursor: args.cursor as string | undefined,
limit: args.limit as number | undefined,
}),
);
}
const prepared = prepareActionCall(args, callContext, 'write');
if ('structuredContent' in prepared) {
return prepared;
}
if (!prepared.catalog.stream) {
return asError('后台任务仅支持流式传输动作;普通动作请使用原执行工具。');
}
mcpReq.signal.throwIfAborted();
return ctx.tasks.start(async ({ signal }) =>
callTool('invoke_action', args, {
mode: ctx.mode,
client: ctx.client,
signal,
trace,
}),
);
}
return callTool(req.params.name, args, callContext);
};
const result = await dispatch();
if (isInputRequiredResult(result)) {
report('call_input_required', 'unknown');
return { ...result, _meta: { traceparent: trace.traceparent } };
}
if (!('structuredContent' in result)) {
report('call_detached', 'unknown', undefined, result.taskId);
return { ...result, _meta: { traceparent: trace.traceparent } };
}
if (!result.isError) {
await progress?.finish();
}
const { outcome, kind } = result.structuredContent.diagnostics;
report(
mcpReq.signal.aborted || kind === 'cancelled' ? 'call_cancelled' : 'call_finished',
outcome,
kind,
);
return { ...result, _meta: { ...result._meta, traceparent: trace.traceparent } };
} catch (error) {
report(mcpReq.signal.aborted ? 'call_cancelled' : 'call_failed', 'unknown');
throw error;
}
});
server.setRequestHandler('resources/list', async (_req, request) => ({
resources: [
{
uri: RESOURCE_URI,
name: 'SnowLuma OneBot action catalog',
mimeType: 'application/json',
description: `SnowLuma v${VERSION} 的 ${ACTIONS.length} 个 OneBot action 完整目录(文档 + JSON Schema)。`,
},
...messageAppResources(supportsMessageApp(capabilities(request))),
...(events?.resources() ?? []),
],
}));
server.setRequestHandler('resources/templates/list', async () => ({ resourceTemplates: [] }));
server.setRequestHandler('resources/read', async (req) => {
if (events?.ownsUri(req.params.uri)) {
return { ...events.readResource(req.params.uri), ...cacheMetadata('private') };
}
const app = await readMessageAppResource(req.params.uri);
if (app) {
return { ...app, ...cacheMetadata('public') };
}
if (req.params.uri !== RESOURCE_URI) {
throw new Error(`unknown resource: ${req.params.uri}`);
}
return {
...cacheMetadata('public'),
contents: [
{
uri: RESOURCE_URI,
mimeType: 'application/json',
text: JSON.stringify(
{ version: VERSION, categories: CATEGORIES, actions: ACTIONS },
null,
2,
),
},
],
};
});
if (events) {
server.setRequestHandler('resources/subscribe', async (req) => {
if (!events.resources().some((resource) => resource.uri === req.params.uri)) {
throw new ProtocolError(-32602, 'Subscribe using the canonical event resource URI');
}
events.readResource(req.params.uri);
legacySubscriptions.add(req.params.uri);
return {};
});
server.setRequestHandler('resources/unsubscribe', async (req) => {
if (!events.ownsUri(req.params.uri)) {
throw new ProtocolError(-32602, 'Unknown event resource');
}
events.remove(req.params.uri);
legacySubscriptions.delete(req.params.uri);
await server.sendResourceListChanged();
return {};
});
}
return server;
}
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env node
// @snowluma/mcp — an MCP server for the SnowLuma OneBot action catalog.
//
// Two modes in one binary:
// • docs (default, no endpoint): read-only catalog tools (list/get/search/
// categories) + a catalog resource. Zero contact with any live instance.
// • read / write (when SNOWLUMA_MCP_ENDPOINT is set): the catalog tools PLUS
// execution — query_action (read-only actions) and, in write mode,
// invoke_action (any known action) — proxied to a running OneBot instance
// over HTTP.
//
// Env:
// SNOWLUMA_MCP_ENDPOINT OneBot HTTP endpoint (e.g. http://127.0.0.1:3000/).
// Absent → docs-only (execution tools hidden).
// SNOWLUMA_MCP_TOKEN access token (Bearer) for the endpoint.
// SNOWLUMA_MCP_TIMEOUT_MS ordinary request timeout; Stream connection and
// per-read idle timeout (default 30s).
// SNOWLUMA_MCP_MODE docs | read | write. Default: read when an
// endpoint is set, else docs.
// SNOWLUMA_MCP_STREAM_DIR MCP-host directory for completed downloads.
// SNOWLUMA_MCP_UPLOAD_ROOT Existing MCP-host directory allowed for uploads.
// SNOWLUMA_MCP_MAX_STREAM_BYTES Per-file cap (1..4 GiB; default 4 GiB).
// SNOWLUMA_MCP_TASK_DIR Private persistent task directory (write mode only).
// SNOWLUMA_MCP_EVENTS_URL Explicit OneBot event WebSocket endpoint.
//
// NOTE: stdout is the MCP protocol channel — all diagnostics go to stderr.
import { serveStdio, StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import { createHash } from 'node:crypto';
import { ACTIONS, CATEGORIES } from './generated/catalog.js';
import {
makeHttpClient,
parseMaxStreamBytes,
parseTimeoutMs,
type ActionClient,
} from './client.js';
import { parseMode, type Mode } from './tools.js';
import { createMcpServer, type McpServerOptions } from './server-factory.js';
import { createInputCompletion } from './elicitation.js';
import { PersistentTasks } from './tasks.js';
import { shutdownRuntime } from './shutdown.js';
import { normalizeEventSubscriptionOptions } from './subscriptions.js';
import { VERSION } from './version.js';
// ── resolve mode + client from the environment ────────────────────────────
function resolveRuntime(): { mode: Mode; client?: ActionClient } {
const endpoint = process.env.SNOWLUMA_MCP_ENDPOINT?.trim();
const token = process.env.SNOWLUMA_MCP_TOKEN?.trim() || undefined;
const streamDir = process.env.SNOWLUMA_MCP_STREAM_DIR?.trim() || undefined;
const uploadRoot = process.env.SNOWLUMA_MCP_UPLOAD_ROOT?.trim() || undefined;
const maxStreamBytes = parseMaxStreamBytes(process.env.SNOWLUMA_MCP_MAX_STREAM_BYTES);
const timeoutMs = parseTimeoutMs(process.env.SNOWLUMA_MCP_TIMEOUT_MS);
const requested = parseMode(process.env.SNOWLUMA_MCP_MODE);
if (!endpoint) {
if (requested && requested !== 'docs') {
console.error(
`[snowluma-mcp] SNOWLUMA_MCP_MODE=${requested} ignored: no SNOWLUMA_MCP_ENDPOINT set — docs-only.`,
);
}
return { mode: 'docs' };
}
const mode: Mode = requested ?? 'read';
if (mode === 'docs') {
// Endpoint set but operator explicitly wants docs-only — honor it.
return { mode: 'docs' };
}
const client = makeHttpClient({
endpoint,
accessToken: token,
timeoutMs,
streamDir,
uploadRoot,
maxStreamBytes,
});
return { mode, client };
}
const runtime: McpServerOptions = resolveRuntime();
const { mode } = runtime;
const taskDirectory = process.env.SNOWLUMA_MCP_TASK_DIR?.trim();
const eventsUrl = process.env.SNOWLUMA_MCP_EVENTS_URL?.trim();
if (taskDirectory && mode !== 'write') {
throw new Error('SNOWLUMA_MCP_TASK_DIR requires write mode and a configured endpoint');
}
if (eventsUrl && mode !== 'docs') {
runtime.events = normalizeEventSubscriptionOptions({
url: eventsUrl,
accessToken: process.env.SNOWLUMA_MCP_TOKEN?.trim() || undefined,
timeoutMs: parseTimeoutMs(process.env.SNOWLUMA_MCP_TIMEOUT_MS),
});
}
if (taskDirectory) {
const scopeId = createHash('sha256')
.update(
JSON.stringify({
mode,
endpoint: process.env.SNOWLUMA_MCP_ENDPOINT?.trim(),
token: process.env.SNOWLUMA_MCP_TOKEN?.trim() || null,
}),
)
.digest('hex');
runtime.tasks = await PersistentTasks.open(taskDirectory, {
scopeId,
onChange: (task) =>
console.error(
'[snowluma-mcp] %s',
JSON.stringify({ event: 'task_status', task_id: task.taskId, status: task.status }),
),
onError: (_error, context) => {
console.error('[snowluma-mcp] %s', JSON.stringify(context));
},
});
}
const inputCompletion = createInputCompletion();
runtime.inputCompletion = inputCompletion;
let cleanup: Promise<void> | undefined;
function closeFeatures(): Promise<void> {
return (cleanup ??= (async () => {
inputCompletion.close();
await runtime.tasks?.close();
})());
}
function reportShutdownError(error: unknown): void {
console.error(
'[snowluma-mcp] shutdown failed: %s',
error instanceof Error ? error.name : 'unknown',
);
process.exitCode = 1;
}
const transport = new StdioServerTransport();
const handle = serveStdio(() => createMcpServer(runtime), {
transport,
onerror: (error) => {
// Protocol errors may embed client input. Keep the error category visible
// without copying arguments or credentials into process diagnostics.
console.error('[snowluma-mcp] protocol error: %s', error.name);
},
});
const transportClosed = transport.onclose;
transport.onclose = () => {
transportClosed?.();
void closeFeatures().catch(reportShutdownError);
};
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.once(signal, () => {
void shutdownRuntime({
closeFeatures,
closeProtocol: () => handle.close(),
forceClose: async () => {
await transport.close();
process.stdout.destroy();
},
onLateProtocolError: reportShutdownError,
}).catch(reportShutdownError);
});
}
console.error(
`[snowluma-mcp] v${VERSION} ready — ${ACTIONS.length} actions, ${CATEGORIES.length} categories, mode=${mode}`,
);
+89
View File
@@ -0,0 +1,89 @@
export interface ShutdownOptions {
closeFeatures(): Promise<void>;
closeProtocol(): Promise<void>;
forceClose(): Promise<void>;
graceMs?: number;
onTimeout?: () => void;
onLateProtocolError?: (error: unknown) => void;
}
/** Persist task shutdown before waiting on a peer that may have stopped reading stdout. */
export async function shutdownRuntime(options: ShutdownOptions): Promise<void> {
const failures: unknown[] = [];
try {
await options.closeFeatures();
} catch (error) {
failures.push(error);
}
const protocol = Promise.resolve()
.then(() => options.closeProtocol())
.then(
() => ({ kind: 'closed' as const }),
(error: unknown) => ({ kind: 'failed' as const, error }),
);
let timer!: ReturnType<typeof setTimeout>;
const timeout = new Promise<{ kind: 'timeout' }>((resolve) => {
timer = setTimeout(() => resolve({ kind: 'timeout' }), options.graceMs ?? 1000);
});
const outcome = await Promise.race([protocol, timeout]);
clearTimeout(timer);
if (outcome.kind === 'failed') {
failures.push(outcome.error);
}
if (outcome.kind === 'timeout') {
// A protocol rejection can arrive after the bounded shutdown returns.
// Observe the settled outcome too, including races with the deadline.
void protocol.then((late) => {
if (late.kind !== 'failed') {
return;
}
try {
if (options.onLateProtocolError) {
options.onLateProtocolError(late.error);
} else {
console.error(
'[snowluma-mcp] protocol shutdown failed after timeout: %s',
late.error instanceof Error ? late.error.name : 'unknown',
);
}
} catch (reportError) {
console.error(
'[snowluma-mcp] late shutdown error reporting failed: %s; original: %s',
reportError instanceof Error ? reportError.name : 'unknown',
late.error instanceof Error ? late.error.name : 'unknown',
);
}
});
try {
if (options.onTimeout) {
options.onTimeout();
} else {
console.error('[snowluma-mcp] protocol shutdown timed out; closing transport');
}
} catch (error) {
failures.push(error);
}
}
if (outcome.kind !== 'closed') {
try {
await options.forceClose();
} catch (error) {
failures.push(error);
}
}
if (failures.length === 1) {
throw failures[0];
}
if (failures.length > 1) {
throw new AggregateError(failures, 'Runtime shutdown failed');
}
}
+722
View File
@@ -0,0 +1,722 @@
import { randomUUID } from 'node:crypto';
import {
ProtocolError,
type ReadResourceResult,
type Resource,
type Tool,
} from '@modelcontextprotocol/server';
import WebSocket, { type RawData } from 'ws';
import { asError, asText, RESULT_SCHEMA, type StructuredResult } from './results.js';
class EventInputError extends ProtocolError {
constructor(message: string) {
super(-32602, message);
}
}
const PREFIX = 'snowluma://onebot/events/';
const MAX_FRAME_BYTES = 256 * 1024;
const MAX_BUFFER_BYTES = 1024 * 1024;
const MAX_BUFFER_EVENTS = 256;
const MAX_SUBSCRIPTIONS = 16;
const MAX_FILTER_IDS = 100;
type EventType = 'message' | 'notice' | 'request';
type SourceStatus = 'connecting' | 'connected' | 'disconnected';
type SourceProblem =
| 'connection_failed'
| 'connection_closed'
| 'connection_timeout'
| 'invalid_event'
| 'account_mismatch'
| 'frame_too_large';
interface Filter {
selfId: number;
types: Set<EventType>;
groupIds?: Set<number>;
userIds?: Set<number>;
}
interface BufferedEvent {
cursor: number;
event: Record<string, unknown>;
bytes: number;
}
interface Subscription {
uri: string;
filter: Filter;
events: BufferedEvent[];
bytes: number;
cursor: number;
dropped: number;
status: SourceStatus;
problem?: SourceProblem;
notificationFailed: boolean;
cancelPending?: () => void;
}
interface Source {
socket: WebSocket;
selfId: number;
ready: Promise<void>;
resolve: () => void;
reject: (error: Error) => void;
verified: boolean;
stopped: boolean;
timer?: ReturnType<typeof setTimeout>;
heartbeat?: ReturnType<typeof setInterval>;
awaitingPong: boolean;
}
export interface EventSubscriptionOptions {
url: string;
accessToken?: string;
timeoutMs?: number;
}
/** Validate startup configuration without creating resources or contacting an account. */
export function normalizeEventSubscriptionOptions(
options: EventSubscriptionOptions,
): EventSubscriptionOptions & { timeoutMs: number } {
let url: URL;
try {
url = new URL(options.url);
} catch {
throw new Error('SNOWLUMA_MCP_EVENTS_URL must be an absolute WebSocket URL');
}
if (
!['ws:', 'wss:'].includes(url.protocol) ||
url.username ||
url.password ||
url.hash ||
url.search
) {
throw new Error(
'SNOWLUMA_MCP_EVENTS_URL must use ws/wss without credentials, query, or fragment; use SNOWLUMA_MCP_TOKEN for authentication',
);
}
if (
options.timeoutMs !== undefined &&
(!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0)
) {
throw new Error('Event connection timeout must be a positive integer');
}
return { ...options, url: url.href, timeoutMs: options.timeoutMs ?? 30_000 };
}
const idSchema = { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER };
const idsSchema = {
type: 'array',
items: idSchema,
minItems: 1,
maxItems: MAX_FILTER_IDS,
uniqueItems: true,
};
export const EVENT_TOOLS: Tool[] = [
{
name: 'subscribe_events',
description:
'订阅指定登录账号的实时 OneBot 事件,返回可读取的资源。消息、通知、请求可按群和用户筛选。只接收创建后的事件;有限缓存溢出和断线会明确标记缺口。通过资源更新通知获知变化,使用 unsubscribe_events 释放资源。',
inputSchema: {
type: 'object',
properties: {
self_id: {
...idSchema,
description:
'预期登录账号,必须与事件连接的账号相同。可先用 get_login_info 查询。',
},
types: {
type: 'array',
items: { type: 'string', enum: ['message', 'notice', 'request'] },
minItems: 1,
maxItems: 3,
uniqueItems: true,
},
group_ids: idsSchema,
user_ids: idsSchema,
},
required: ['self_id'],
additionalProperties: false,
},
outputSchema: RESULT_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
},
{
name: 'unsubscribe_events',
description:
'停止事件订阅并释放其缓存;最后一个事件订阅结束时断开事件连接。不发送任何 OneBot 业务操作。',
inputSchema: {
type: 'object',
properties: { uri: { type: 'string' } },
required: ['uri'],
additionalProperties: false,
},
outputSchema: RESULT_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
},
];
function record(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function id(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
}
function readIds(value: unknown, field: string): Set<number> | undefined {
if (value === undefined) {
return undefined;
}
if (
!Array.isArray(value) ||
value.length === 0 ||
value.length > MAX_FILTER_IDS ||
!value.every(id) ||
new Set(value).size !== value.length
) {
throw new EventInputError(
`${field} must contain 1..${MAX_FILTER_IDS} distinct positive integer IDs`,
);
}
return new Set(value);
}
function filter(args: Record<string, unknown>): Filter {
if (
Object.keys(args).some(
(key) => !['self_id', 'types', 'group_ids', 'user_ids'].includes(key),
)
) {
throw new EventInputError('Unexpected event subscription parameter');
}
if (!id(args.self_id)) {
throw new EventInputError('self_id must be a positive safe integer');
}
const types = args.types ?? ['message', 'notice', 'request'];
if (
!Array.isArray(types) ||
types.length === 0 ||
types.length > 3 ||
types.some((type) => !['message', 'notice', 'request'].includes(type)) ||
new Set(types).size !== types.length
) {
throw new EventInputError('types must contain distinct message, notice, or request values');
}
return {
selfId: args.self_id,
types: new Set(types),
groupIds: readIds(args.group_ids, 'group_ids'),
userIds: readIds(args.user_ids, 'user_ids'),
};
}
function eventType(value: unknown): EventType | undefined {
if (value === 'message' || value === 'message_sent') {
return 'message';
}
if (value === 'notice' || value === 'request') {
return value;
}
return undefined;
}
function hasSafeDepth(value: Record<string, unknown>): boolean {
const pending: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }];
while (pending.length > 0) {
const entry = pending.pop()!;
if (entry.value === null || typeof entry.value !== 'object') {
continue;
}
if (entry.depth >= 64) {
return false;
}
for (const child of Object.values(entry.value)) {
pending.push({ value: child, depth: entry.depth + 1 });
}
}
return true;
}
/** Private, bounded event resources for one stdio client. Constructing does not connect. */
export class EventSubscriptions {
private readonly options: EventSubscriptionOptions;
private readonly subscriptions = new Map<string, Subscription>();
private readonly listeners = new Set<(uri: string) => Promise<void>>();
private readonly pendingNotifications = new Set<string>();
private notificationTimer?: ReturnType<typeof setTimeout>;
private notifying = false;
private source?: Source;
private closed = false;
constructor(options: EventSubscriptionOptions) {
this.options = normalizeEventSubscriptionOptions(options);
}
handlesTool(name: string): boolean {
return EVENT_TOOLS.some((tool) => tool.name === name);
}
async callTool(
name: string,
args: Record<string, unknown>,
signal?: AbortSignal,
): Promise<StructuredResult> {
try {
if (signal?.aborted) {
return asError('事件订阅操作已取消。', 'cancelled');
}
if (name === 'subscribe_events') {
const subscription = await this.create(filter(args), signal);
return asText(this.snapshot(subscription, undefined));
}
if (name === 'unsubscribe_events') {
if (
typeof args.uri !== 'string' ||
Object.keys(args).some((key) => key !== 'uri')
) {
throw new EventInputError('unsubscribe_events requires only a resource uri');
}
this.remove(args.uri);
return asText({ uri: args.uri, unsubscribed: true });
}
return asError('Unknown event tool', 'not_found');
} catch (error) {
if (signal?.aborted) {
return asError('事件订阅操作已取消。', 'cancelled');
}
if (error instanceof EventInputError) {
return asError(error.message, 'invalid_arguments');
}
// Socket errors can contain URLs and server-controlled strings. Keep
// those out of both tool diagnostics and stderr.
console.error('[snowluma-mcp] event subscription establishment failed');
return asError(
'事件订阅未建立;检查事件地址、凭据、预期账号及服务端状态。',
'transport',
);
}
}
resources(): Resource[] {
return Array.from(this.subscriptions.values(), ({ uri }) => ({
uri,
name: 'OneBot live events',
mimeType: 'application/json',
description:
'有界实时事件队列。资源 URI 加 ?after=游标 可增量读取;gap=true 表示事件不完整。',
}));
}
ownsUri(uri: string): boolean {
return uri.startsWith(PREFIX);
}
readResource(uri: string): ReadResourceResult {
const { subscription, after } = this.lookup(uri);
return {
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify(this.snapshot(subscription, after)),
},
],
};
}
/** Legacy unsubscribe and the explicit tool both release the source lease. */
remove(uri: string): void {
const { subscription, after } = this.lookup(uri);
if (after !== undefined) {
throw new EventInputError('Use the canonical event resource URI to unsubscribe');
}
this.subscriptions.delete(subscription.uri);
subscription.cancelPending?.();
this.pendingNotifications.delete(subscription.uri);
if (this.subscriptions.size === 0) {
this.stopSource();
}
}
onUpdate(listener: (uri: string) => Promise<void>): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
close(): void {
this.closed = true;
this.stopSource();
this.subscriptions.clear();
this.listeners.clear();
this.pendingNotifications.clear();
clearTimeout(this.notificationTimer);
}
private lookup(uri: string): { subscription: Subscription; after?: number } {
const match =
/^snowluma:\/\/onebot\/events\/([a-f0-9-]{36})(?:\?after=(0|[1-9]\d*))?$/.exec(uri);
if (!match) {
throw new EventInputError('Invalid event resource URI');
}
const subscription = this.subscriptions.get(`${PREFIX}${match[1]}`);
const after = match[2] === undefined ? undefined : Number(match[2]);
if (!subscription) {
throw new EventInputError('Event resource not found or already unsubscribed');
}
if (after !== undefined && (!Number.isSafeInteger(after) || after > subscription.cursor)) {
throw new EventInputError('Event cursor is beyond the available stream');
}
return { subscription, after };
}
private snapshot(subscription: Subscription, after: number | undefined) {
const oldest = subscription.events[0]?.cursor ?? subscription.cursor + 1;
return {
uri: subscription.uri,
self_id: subscription.filter.selfId,
status: subscription.status,
...(subscription.problem ? { problem: subscription.problem } : {}),
next_cursor: subscription.cursor,
oldest_cursor: oldest,
dropped_events: subscription.dropped,
gap: subscription.status === 'disconnected' || (after ?? 0) < oldest - 1,
notification_failed: subscription.notificationFailed,
events: subscription.events
.filter((entry) => after === undefined || entry.cursor > after)
.map(({ cursor, event }) => ({ cursor, event })),
};
}
private async create(selected: Filter, signal?: AbortSignal): Promise<Subscription> {
if (this.closed) {
throw new EventInputError('Event resources are closed');
}
if (this.subscriptions.size >= MAX_SUBSCRIPTIONS) {
throw new EventInputError(`At most ${MAX_SUBSCRIPTIONS} event resources may be active`);
}
if (this.source && (this.source.selfId !== selected.selfId || this.source.stopped)) {
throw new EventInputError(
'Release existing event resources before changing accounts or reconnecting',
);
}
const subscription: Subscription = {
uri: `${PREFIX}${randomUUID()}`,
filter: selected,
events: [],
bytes: 0,
cursor: 0,
dropped: 0,
status: 'connecting',
notificationFailed: false,
};
this.subscriptions.set(subscription.uri, subscription);
let onAbort: (() => void) | undefined;
try {
const source = this.source ?? this.connect(selected.selfId);
await Promise.race([
source.ready,
new Promise<never>((_resolve, reject) => {
onAbort = () => reject(new Error('Event subscription cancelled'));
subscription.cancelPending = onAbort;
signal?.addEventListener('abort', onAbort, { once: true });
if (signal?.aborted) {
onAbort();
}
}),
]);
if (
source.stopped ||
signal?.aborted ||
this.subscriptions.get(subscription.uri) !== subscription
) {
throw new Error('Event connection unavailable');
}
subscription.status = 'connected';
return subscription;
} catch (error) {
this.subscriptions.delete(subscription.uri);
this.pendingNotifications.delete(subscription.uri);
if (this.subscriptions.size === 0) {
this.stopSource();
}
throw error;
} finally {
subscription.cancelPending = undefined;
if (onAbort) {
signal?.removeEventListener('abort', onAbort);
}
}
}
private connect(selfId: number): Source {
let resolve!: () => void;
let reject!: (error: Error) => void;
const ready = new Promise<void>((accept, fail) => {
resolve = accept;
reject = fail;
});
const socket = new WebSocket(this.options.url, {
headers: this.options.accessToken
? { Authorization: `Bearer ${this.options.accessToken}` }
: undefined,
maxPayload: MAX_FRAME_BYTES,
handshakeTimeout: this.options.timeoutMs,
followRedirects: false,
perMessageDeflate: false,
});
const source: Source = {
socket,
selfId,
ready,
resolve,
reject,
verified: false,
stopped: false,
awaitingPong: false,
};
this.source = source;
source.timer = setTimeout(
() => this.failSource(source, 'connection_timeout'),
this.options.timeoutMs,
);
source.timer.unref();
socket.on('message', (raw: RawData, binary: boolean) => this.receive(source, raw, binary));
socket.on('error', (error: Error & { code?: string }) =>
this.failSource(
source,
error.code === 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
? 'frame_too_large'
: 'connection_failed',
),
);
socket.on('close', () => this.failSource(source, 'connection_closed'));
socket.on('pong', () => {
source.awaitingPong = false;
});
return source;
}
private receive(source: Source, raw: RawData, binary: boolean): void {
if (source.stopped || source !== this.source) {
return;
}
let event: unknown;
let byteLength: number;
try {
if (binary) {
throw new Error('Binary event frame');
}
const bytes = Array.isArray(raw)
? Buffer.concat(raw)
: raw instanceof ArrayBuffer
? Buffer.from(raw)
: raw;
byteLength = bytes.length;
event = JSON.parse(bytes.toString('utf8'));
} catch {
this.failSource(source, 'invalid_event');
return;
}
if (
!record(event) ||
!id(event.self_id) ||
typeof event.post_type !== 'string' ||
!hasSafeDepth(event)
) {
this.failSource(source, 'invalid_event');
return;
}
if (event.self_id !== source.selfId) {
this.failSource(source, 'account_mismatch');
return;
}
if (!source.verified) {
source.verified = true;
clearTimeout(source.timer);
source.heartbeat = setInterval(() => {
if (source.stopped) {
return;
}
if (source.awaitingPong) {
this.failSource(source, 'connection_timeout');
return;
}
source.awaitingPong = true;
source.socket.ping();
}, 30_000);
source.heartbeat.unref();
source.resolve();
for (const subscription of this.subscriptions.values()) {
subscription.status = 'connected';
this.updated(subscription.uri);
}
}
const type = eventType(event.post_type);
if (!type) {
return;
}
for (const subscription of this.subscriptions.values()) {
const selected = subscription.filter;
if (
!selected.types.has(type) ||
(selected.groupIds &&
(!id(event.group_id) || !selected.groupIds.has(event.group_id))) ||
(selected.userIds && (!id(event.user_id) || !selected.userIds.has(event.user_id)))
) {
continue;
}
subscription.events.push({ cursor: ++subscription.cursor, event, bytes: byteLength });
subscription.bytes += byteLength;
while (
subscription.events.length > MAX_BUFFER_EVENTS ||
subscription.bytes > MAX_BUFFER_BYTES
) {
const dropped = subscription.events.shift()!;
subscription.bytes -= dropped.bytes;
subscription.dropped++;
}
this.updated(subscription.uri);
}
}
private failSource(source: Source, problem: SourceProblem): void {
if (source.stopped) {
return;
}
source.stopped = true;
clearTimeout(source.timer);
clearInterval(source.heartbeat);
source.reject(new Error('Event source unavailable'));
source.socket.terminate();
console.error('[snowluma-mcp] event source stopped: %s', problem);
for (const subscription of this.subscriptions.values()) {
subscription.status = 'disconnected';
subscription.problem = problem;
this.updated(subscription.uri);
}
}
private stopSource(): void {
if (!this.source) {
return;
}
const source = this.source;
source.stopped = true;
clearTimeout(source.timer);
clearInterval(source.heartbeat);
source.reject(new Error('Event source closed'));
source.socket.terminate();
this.source = undefined;
}
private updated(uri: string): void {
this.pendingNotifications.add(uri);
if (this.notificationTimer || this.notifying || this.closed) {
return;
}
this.notificationTimer = setTimeout(() => {
this.notificationTimer = undefined;
void this.flushNotifications();
}, 25);
this.notificationTimer.unref();
}
private async flushNotifications(): Promise<void> {
this.notifying = true;
const uris = Array.from(this.pendingNotifications);
this.pendingNotifications.clear();
try {
for (const uri of uris) {
if (!this.subscriptions.has(uri)) {
continue;
}
for (const listener of this.listeners) {
try {
await listener(uri);
} catch {
const subscription = this.subscriptions.get(uri);
if (subscription) {
subscription.notificationFailed = true;
}
console.error('[snowluma-mcp] event resource notification failed');
}
}
}
} finally {
this.notifying = false;
if (!this.closed && this.pendingNotifications.size > 0) {
this.updated(this.pendingNotifications.values().next().value!);
}
}
}
}
+956
View File
@@ -0,0 +1,956 @@
import { randomUUID } from 'node:crypto';
import { constants } from 'node:fs';
import { lstat, mkdir, open, readdir, realpath, rename, unlink } from 'node:fs/promises';
import { hostname } from 'node:os';
import path from 'node:path';
import {
ProtocolError,
ProtocolErrorCode,
specTypeSchemas,
type CallToolResult,
type InputRequests,
type InputResponses,
type Server,
type ServerContext,
type StandardSchemaV1,
} from '@modelcontextprotocol/server';
// The 2026 Tasks extension is server-directed and has get/update/cancel,
// not the deprecated 2025 tasks/result or tasks/list methods.
export const TASK_EXTENSION = 'io.modelcontextprotocol/tasks';
export const TASK_CAPABILITIES = { extensions: { [TASK_EXTENSION]: {} } };
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const MAX_RECORD_BYTES = 16 * 1024 * 1024;
const INTERRUPTED = 'Task interrupted; remote outcome is unknown. Work was not replayed.';
const CANCELLED = 'Task cancelled; cancellation does not undo remote effects.';
const CANCELLATION_REQUESTED = 'Cancellation requested; remote effects may already have occurred.';
interface TaskBase {
taskId: string;
createdAt: string;
lastUpdatedAt: string;
ttlMs: null;
pollIntervalMs: number;
statusMessage?: string;
}
export type PersistentTask = TaskBase &
(
| { status: 'working' }
| { status: 'input_required'; inputRequests: InputRequests }
| { status: 'completed'; result: CallToolResult & { resultType: 'complete' } }
| { status: 'failed'; error: { code: number; message: string; data?: unknown } }
| { status: 'cancelled' }
);
export type TaskSummary = TaskBase & { status: PersistentTask['status'] };
export type TaskHandle = TaskSummary & { resultType: 'task'; content: [] };
export interface TaskExecutionContext {
taskId: string;
signal: AbortSignal;
requestInput(requests: InputRequests): Promise<InputResponses>;
}
export interface TaskStoreOptions {
/** Stable, non-secret digest of the configured account/endpoint and access mode. */
scopeId: string;
onChange?: (task: PersistentTask) => void | Promise<void>;
onError?: (error: Error, context: { event: string; taskId?: string }) => void;
maxConcurrent?: number;
}
interface PendingInput {
outstanding: InputRequests;
responses: InputResponses;
resolve: (responses: InputResponses) => void;
reject: (error: Error) => void;
}
interface ActiveTask {
controller: AbortController;
usedInputKeys: Set<string>;
input?: PendingInput;
}
function object(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function supportsTasks(meta?: Readonly<Record<string, unknown>>): boolean {
const capabilities = meta?.['io.modelcontextprotocol/clientCapabilities'];
if (!object(capabilities) || !object(capabilities.extensions)) {
return false;
}
const extension = capabilities.extensions[TASK_EXTENSION];
return object(extension) && Object.keys(extension).length === 0;
}
function requireTasks(ctx: ServerContext, version: string | undefined): void {
if (!version || version < '2026-07-28' || !supportsTasks(ctx.mcpReq.envelope)) {
throw new ProtocolError(-32021, 'The request must declare the Tasks extension capability', {
requiredCapabilities: TASK_CAPABILITIES,
});
}
}
function taskId(value: unknown): string {
if (typeof value !== 'string' || !ID.test(value)) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid task identifier');
}
return value;
}
function summary(task: PersistentTask): TaskSummary {
const { taskId, status, createdAt, lastUpdatedAt, ttlMs, pollIntervalMs, statusMessage } = task;
return {
taskId,
status,
createdAt,
lastUpdatedAt,
ttlMs,
pollIntervalMs,
...(statusMessage ? { statusMessage } : {}),
};
}
function terminal(task: PersistentTask): boolean {
return task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled';
}
function errorCode(error: unknown): string | undefined {
return object(error) && typeof error.code === 'string' ? error.code : undefined;
}
async function readPrivateJson(file: string): Promise<unknown> {
const stat = await lstat(file);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
(process.platform !== 'win32' && (stat.mode & 0o077) !== 0)
) {
throw new Error('Task storage file must be a private regular file');
}
if (stat.size > MAX_RECORD_BYTES) {
throw new Error('Task storage record exceeds its size limit');
}
const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
try {
const contents = await handle.readFile('utf8');
try {
return JSON.parse(contents);
} catch {
throw new Error('Invalid JSON in task storage');
}
} finally {
await handle.close();
}
}
function validateRecord(value: unknown): PersistentTask {
if (!object(value) || value.version !== 1 || !object(value.task)) {
throw new Error('Invalid task storage record');
}
const task = value.task;
taskId(task.taskId);
if (
typeof task.createdAt !== 'string' ||
!Number.isFinite(Date.parse(task.createdAt)) ||
typeof task.lastUpdatedAt !== 'string' ||
!Number.isFinite(Date.parse(task.lastUpdatedAt)) ||
task.ttlMs !== null ||
task.pollIntervalMs !== 1000 ||
(task.statusMessage !== undefined && typeof task.statusMessage !== 'string')
) {
throw new Error('Invalid task storage metadata');
}
const base: TaskBase = {
taskId: taskId(task.taskId),
createdAt: task.createdAt,
lastUpdatedAt: task.lastUpdatedAt,
ttlMs: null,
pollIntervalMs: 1000,
...(typeof task.statusMessage === 'string' ? { statusMessage: task.statusMessage } : {}),
};
switch (task.status) {
case 'working':
return { ...base, status: 'working' };
case 'input_required': {
// In-flight work is never resumed, but corruption must not be hidden.
const requests = validateInputRequests(task.inputRequests);
return { ...base, status: 'input_required', inputRequests: requests };
}
case 'cancelled':
return { ...base, status: 'cancelled' };
case 'failed': {
if (
!object(task.error) ||
!Number.isSafeInteger(task.error.code) ||
typeof task.error.message !== 'string'
) {
throw new Error('Invalid persisted task error');
}
return {
...base,
status: 'failed',
error: {
code: Number(task.error.code),
message: task.error.message,
...(task.error.data !== undefined ? { data: task.error.data } : {}),
},
};
}
case 'completed': {
const validated = specTypeSchemas.CallToolResult['~standard'].validate(task.result);
if (validated.issues) {
throw new Error('Invalid persisted task result');
}
return {
...base,
status: 'completed',
result: { ...validated.value, resultType: 'complete' },
};
}
default:
throw new Error('Unknown persisted task status');
}
}
function validateInputRequests(value: unknown): InputRequests {
if (!object(value) || Object.keys(value).length === 0) {
throw new Error('Task input requests must be a non-empty object');
}
const requests: InputRequests = Object.create(null);
for (const [key, request] of Object.entries(value)) {
if (!object(request)) {
throw new Error('Invalid task input request');
}
const schema =
request.method === 'elicitation/create'
? specTypeSchemas.ElicitRequest
: request.method === 'sampling/createMessage'
? specTypeSchemas.CreateMessageRequest
: request.method === 'roots/list'
? specTypeSchemas.ListRootsRequest
: undefined;
const parsed = schema?.['~standard'].validate(request);
if (!parsed || parsed.issues) {
throw new Error('Invalid task input request');
}
requests[key] = parsed.value;
}
return requests;
}
/** One runtime owns one private directory; callbacks and credentials are never persisted. */
export class PersistentTasks {
private readonly records = new Map<string, PersistentTask>();
private readonly active = new Map<string, ActiveTask>();
private queue: Promise<void> = Promise.resolve();
private notifications: Promise<void> = Promise.resolve();
private closed = false;
private closingRequested = false;
private leaseReleased = false;
private persistenceError?: Error;
private constructor(
private readonly directory: string,
private readonly lease: string,
private readonly options: TaskStoreOptions,
) {}
static async open(directory: string, options: TaskStoreOptions): Promise<PersistentTasks> {
if (!path.isAbsolute(directory) || !/^[a-f0-9]{64}$/.test(options.scopeId)) {
throw new Error(
'Task storage requires an absolute directory and a SHA-256 scope identifier',
);
}
if (
options.maxConcurrent !== undefined &&
(!Number.isSafeInteger(options.maxConcurrent) || options.maxConcurrent < 1)
) {
throw new Error('Task concurrency must be a positive integer');
}
await mkdir(directory, { recursive: true, mode: 0o700 });
const stat = await lstat(directory);
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
(process.platform !== 'win32' && (stat.mode & 0o077) !== 0)
) {
throw new Error(
'Task storage directory must be private (0700) and cannot be a symlink',
);
}
const resolved = await realpath(directory);
const lease = `.lease-${randomUUID()}.json`;
const store = new PersistentTasks(resolved, lease, options);
try {
await store.save(lease, { pid: process.pid, host: hostname() });
// Publishing our lease before inspecting peers makes concurrent starts
// mutually exclusive. A dead owner's lease is recoverable after a crash.
for (const name of await readdir(resolved)) {
if (!/^\.lease-[a-f0-9-]+\.json$/.test(name) || name === lease) {
continue;
}
let owner: unknown;
try {
owner = await readPrivateJson(path.join(resolved, name));
} catch (error) {
if (errorCode(error) === 'ENOENT') {
continue;
}
throw error;
}
if (
!object(owner) ||
!Number.isSafeInteger(owner.pid) ||
Number(owner.pid) <= 0 ||
owner.host !== hostname()
) {
throw new Error('Task storage has an invalid or foreign-host lock');
}
try {
process.kill(Number(owner.pid), 0);
throw new Error('Task storage is locked by another runtime');
} catch (error) {
if (errorCode(error) !== 'ESRCH') {
throw error;
}
}
await unlink(path.join(resolved, name)).catch((error: unknown) => {
if (errorCode(error) !== 'ENOENT') {
throw error;
}
});
store.report('recovered_stale_lock');
}
const entries = await readdir(resolved);
if (entries.includes('scope.json')) {
const scope = await readPrivateJson(path.join(resolved, 'scope.json'));
if (!object(scope) || scope.version !== 1 || scope.scopeId !== options.scopeId) {
throw new Error(
'Task storage belongs to a different account or permission scope',
);
}
} else {
if (entries.some((name) => name.endsWith('.task.json'))) {
throw new Error('Task storage is missing its scope record');
}
await store.save('scope.json', { version: 1, scopeId: options.scopeId });
}
for (const name of entries) {
if (!name.endsWith('.task.json')) {
continue;
}
const id = taskId(name.slice(0, -10));
let task = validateRecord(await readPrivateJson(path.join(resolved, name)));
if (task.taskId !== id) {
throw new Error('Task storage identifier mismatch');
}
if (!terminal(task)) {
task = {
...summary(task),
status: 'failed',
lastUpdatedAt: new Date().toISOString(),
statusMessage: INTERRUPTED,
error: { code: -32603, message: INTERRUPTED },
};
await store.save(name, { version: 1, task });
store.report('interrupted_task_not_replayed', id);
}
store.records.set(id, task);
}
return store;
} catch (error) {
await unlink(path.join(resolved, lease)).catch((cleanupError: unknown) => {
if (errorCode(cleanupError) !== 'ENOENT') {
throw new AggregateError(
[error, cleanupError],
'Task startup and lock cleanup failed',
);
}
});
throw error;
}
}
private report(event: string, id?: string): void {
const error = new Error(`Task runtime: ${event}`);
if (this.options.onError) {
try {
this.options.onError(error, { event, ...(id ? { taskId: id } : {}) });
} catch {
console.error('[snowluma-mcp] task diagnostic callback failed');
}
} else {
console.error(
'[snowluma-mcp] %s',
JSON.stringify({ event, ...(id ? { taskId: id } : {}) }),
);
}
}
private async save(name: string, data: unknown): Promise<void> {
const serialized = JSON.stringify(data);
if (Buffer.byteLength(serialized) > MAX_RECORD_BYTES) {
throw new Error('Task record exceeds its size limit');
}
const temporary = path.join(this.directory, `.tmp-${randomUUID()}`);
const file = await open(temporary, 'wx', 0o600);
try {
await file.writeFile(serialized);
await file.sync();
} finally {
await file.close();
}
await rename(temporary, path.join(this.directory, name));
// Windows does not support opening directories for fsync.
if (process.platform !== 'win32') {
const directory = await open(this.directory, 'r');
try {
await directory.sync();
} finally {
await directory.close();
}
}
}
private serial<T>(operation: () => Promise<T>): Promise<T> {
const next = this.queue.then(operation);
this.queue = next.then(
() => undefined,
() => undefined,
);
return next;
}
private async put(task: PersistentTask): Promise<void> {
try {
await this.save(`${task.taskId}.task.json`, { version: 1, task });
} catch (error) {
this.persistenceError = new Error(
'Task persistence failed; stop using this runtime and inspect its storage',
{ cause: error },
);
throw this.persistenceError;
}
this.records.set(task.taskId, task);
if (this.options.onChange) {
const snapshot = structuredClone(task);
// Subscription backpressure must not block durable state or cancel.
this.notifications = this.notifications
.then(() => this.options.onChange?.(snapshot))
.catch(() => {
this.report('task_notification_failed', task.taskId);
});
}
}
get(id: string): PersistentTask {
if (this.persistenceError) {
throw this.persistenceError;
}
const task = this.records.get(taskId(id));
if (!task) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Unknown task identifier');
}
return structuredClone(task);
}
list(options: { cursor?: string; limit?: number } = {}): {
tasks: TaskSummary[];
nextCursor?: string;
} {
if (this.persistenceError) {
throw this.persistenceError;
}
const limit = options.limit ?? 50;
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
'Task page size must be between 1 and 100',
);
}
const entries = [...this.records.values()].sort(
(a, b) => a.createdAt.localeCompare(b.createdAt) || a.taskId.localeCompare(b.taskId),
);
const previous =
options.cursor === undefined
? -1
: entries.findIndex((task) => task.taskId === taskId(options.cursor));
if (options.cursor !== undefined && previous < 0) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Unknown task cursor');
}
const page = entries.slice(previous + 1, previous + 1 + limit);
return {
tasks: page.map(summary),
...(previous + 1 + page.length < entries.length
? { nextCursor: page.at(-1)!.taskId }
: {}),
};
}
async start(
execute: (context: TaskExecutionContext) => Promise<CallToolResult>,
): Promise<TaskHandle> {
if (this.closingRequested) {
throw new Error('Task runtime is closed');
}
const task = await this.serial(async () => {
if (this.closed) {
throw new Error('Task runtime is closed');
}
if (this.persistenceError) {
throw this.persistenceError;
}
if (this.active.size >= (this.options.maxConcurrent ?? 4)) {
throw new Error('Task concurrency limit reached');
}
const now = new Date().toISOString();
const task: PersistentTask = {
taskId: randomUUID(),
status: 'working',
createdAt: now,
lastUpdatedAt: now,
ttlMs: null,
pollIntervalMs: 1000,
};
await this.put(task);
this.active.set(task.taskId, {
controller: new AbortController(),
usedInputKeys: new Set(),
});
return task;
});
// Persist the handle before starting a callback that can have side effects.
void this.run(task.taskId, execute);
return { ...summary(task), resultType: 'task', content: [] };
}
private async run(
id: string,
execute: (context: TaskExecutionContext) => Promise<CallToolResult>,
): Promise<void> {
// close() owns pending records too; keep them registered until it has
// durably marked their outcome instead of dropping them in finally.
if (this.closingRequested) {
return;
}
const active = this.active.get(id)!;
try {
if (this.persistenceError) {
throw this.persistenceError;
}
if (active.controller.signal.aborted) {
throw active.controller.signal.reason;
}
const result = await execute({
taskId: id,
signal: active.controller.signal,
requestInput: (requests) => this.requestInput(id, requests),
});
const validated = specTypeSchemas.CallToolResult['~standard'].validate(result);
if (validated.issues) {
throw new Error('Invalid task execution result');
}
await this.serial(async () => {
if (this.closed || terminal(this.get(id))) {
return;
}
// A successful late completion can win a cooperative cancellation.
await this.put({
...summary(this.get(id)),
status: 'completed',
statusMessage: undefined,
lastUpdatedAt: new Date().toISOString(),
result: { ...validated.value, resultType: 'complete' },
});
});
} catch (error) {
try {
await this.serial(async () => {
if (this.closed || terminal(this.get(id))) {
return;
}
const base = {
...summary(this.get(id)),
lastUpdatedAt: new Date().toISOString(),
};
await this.put(
active.controller.signal.aborted
? { ...base, status: 'cancelled', statusMessage: CANCELLED }
: {
...base,
status: 'failed',
statusMessage:
'Task execution failed; check the correlated call diagnostics.',
error: {
code: error instanceof ProtocolError ? error.code : -32603,
message:
error instanceof ProtocolError
? error.message
: 'Task execution failed; remote outcome may be unknown.',
...(error instanceof ProtocolError && error.data !== undefined
? { data: error.data }
: {}),
},
},
);
});
} catch {
this.report('task_persistence_failed', id);
}
if (!active.controller.signal.aborted) {
this.report('task_execution_failed', id);
}
} finally {
active.input?.reject(new Error('Task execution ended'));
this.active.delete(id);
}
}
private async requestInput(id: string, requests: InputRequests): Promise<InputResponses> {
const validated = validateInputRequests(requests);
const pending = await this.serial(async () => {
const active = this.active.get(id);
if (
!active ||
active.controller.signal.aborted ||
this.closed ||
terminal(this.get(id))
) {
throw new Error(CANCELLED);
}
if (active.input) {
throw new Error('Task already has pending input');
}
if (Object.keys(validated).some((key) => active.usedInputKeys.has(key))) {
throw new Error('Task input keys must be unique for its entire lifetime');
}
await this.put({
...summary(this.get(id)),
status: 'input_required',
lastUpdatedAt: new Date().toISOString(),
inputRequests: validated,
});
let resolve!: (responses: InputResponses) => void;
let reject!: (error: Error) => void;
const promise = new Promise<InputResponses>((yes, no) => {
resolve = yes;
reject = no;
});
active.input = {
outstanding: validated,
responses: Object.create(null),
resolve,
reject,
};
for (const key of Object.keys(validated)) {
active.usedInputKeys.add(key);
}
return { promise };
});
return pending.promise;
}
async update(id: string, responses: unknown): Promise<void> {
if (!object(responses)) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
'Input responses must be an object',
);
}
await this.serial(async () => {
if (this.closed) {
throw new Error('Task runtime is closed');
}
const task = this.get(id);
const active = this.active.get(id);
const pending = active?.input;
if (task.status !== 'input_required' || !pending) {
return;
}
const accepted: InputResponses = Object.create(null);
for (const [key, response] of Object.entries(responses)) {
if (!Object.hasOwn(pending.outstanding, key)) {
continue;
}
const request = pending.outstanding[key];
if (!request) {
continue;
}
const schema =
request.method === 'elicitation/create'
? specTypeSchemas.ElicitResult
: request.method === 'sampling/createMessage'
? specTypeSchemas.CreateMessageResult
: specTypeSchemas.ListRootsResult;
const parsed = schema['~standard'].validate(response);
if (parsed.issues) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
'Invalid task input response',
);
}
accepted[key] = parsed.value;
}
if (Object.keys(accepted).length === 0) {
return;
}
const outstanding = { ...pending.outstanding };
for (const key of Object.keys(accepted)) {
delete outstanding[key];
}
const done = Object.keys(outstanding).length === 0;
await this.put(
done
? {
...summary(task),
status: 'working',
lastUpdatedAt: new Date().toISOString(),
}
: {
...summary(task),
status: 'input_required',
lastUpdatedAt: new Date().toISOString(),
inputRequests: outstanding,
},
);
Object.assign(pending.responses, accepted);
pending.outstanding = outstanding;
if (done) {
active.input = undefined;
pending.resolve(pending.responses);
}
});
}
async cancel(id: string): Promise<void> {
await this.serial(async () => {
if (this.closed) {
throw new Error('Task runtime is closed');
}
const task = this.get(id);
if (terminal(task)) {
return;
}
const active = this.active.get(id);
if (!active) {
throw new Error('Task execution is unavailable');
}
await this.put({
...task,
lastUpdatedAt: new Date().toISOString(),
statusMessage: CANCELLATION_REQUESTED,
});
active.controller.abort(new Error(CANCELLED));
active.input?.reject(new Error(CANCELLED));
});
}
async close(): Promise<void> {
this.closingRequested = true;
await this.serial(async () => {
if (this.leaseReleased) {
return;
}
this.closed = true;
const activeTasks = [...this.active.entries()];
// Abort all executions even when persistence has failed. Failed writes
// still reject close; a later process will terminate the durable state.
for (const [, active] of activeTasks) {
active.controller.abort(new Error(INTERRUPTED));
active.input?.reject(new Error(INTERRUPTED));
}
let failure: unknown;
for (const [id] of activeTasks) {
const task = this.records.get(id)!;
try {
if (!terminal(task)) {
await this.put({
...summary(task),
status: 'failed',
lastUpdatedAt: new Date().toISOString(),
statusMessage: INTERRUPTED,
error: { code: -32603, message: INTERRUPTED },
});
}
} catch (error) {
failure ??= error;
}
}
await unlink(path.join(this.directory, this.lease));
this.leaseReleased = true;
this.active.clear();
if (failure) {
throw failure;
}
});
}
}
const taskParams: StandardSchemaV1<unknown, { taskId: string }> = {
'~standard': {
version: 1 as const,
vendor: 'snowluma',
validate(value: unknown) {
if (!object(value) || typeof value.taskId !== 'string' || !ID.test(value.taskId)) {
return { issues: [{ message: 'Invalid task identifier' }] };
}
return { value: { taskId: value.taskId } };
},
},
};
export function installTaskHandlers(
server: Server,
tasks: PersistentTasks,
options: { authorize?: (ctx: ServerContext) => void } = {},
): void {
const authorize = (ctx: ServerContext) => {
requireTasks(ctx, server.getNegotiatedProtocolVersion());
options.authorize?.(ctx);
};
server.registerCapabilities(TASK_CAPABILITIES);
server.setRequestHandler('tasks/get', { params: taskParams }, (params, ctx) => {
authorize(ctx);
return { ...tasks.get(params.taskId) };
});
server.setRequestHandler('tasks/cancel', { params: taskParams }, async (params, ctx) => {
authorize(ctx);
await tasks.cancel(params.taskId);
return {};
});
server.setRequestHandler('tasks/update', { params: taskParams }, async (params, ctx) => {
authorize(ctx);
const task = tasks.get(params.taskId);
const invalidOutstanding =
task.status === 'input_required' &&
ctx.mcpReq.droppedInputResponseKeys?.some((key) =>
Object.hasOwn(task.inputRequests, key),
);
if (!ctx.mcpReq.inputResponses || invalidOutstanding) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
'Invalid task input responses',
);
}
await tasks.update(params.taskId, ctx.mcpReq.inputResponses);
return {};
});
}
+445
View File
@@ -0,0 +1,445 @@
// Tool surface for the MCP — kept free of any stdio / transport / env concerns
// so it is directly unit-testable. `server.ts` reads the environment, builds a
// client, and wires `computeTools` / `callTool` into the MCP request handlers.
//
// Two execution tools partition the action space by side-effect:
// • query_action — read-only actions only (readOnlyHint)
// • invoke_action — any known action (destructiveHint, write mode)
// Visibility is gated by `mode`, and `callTool` RE-checks every permission
// (defense in depth: a client may call a tool that was never listed).
import type { Tool } from '@modelcontextprotocol/server';
import { ACTIONS, CATEGORIES } from './generated/catalog.js';
import type { CatalogAction } from './types.js';
import type { ActionClient } from './client.js';
import { MCP_UPLOAD_OWNED_FIELDS } from './client.js';
import { discover, FILTER_PROPERTIES } from './discovery.js';
import {
asText,
asError,
envelopeDiagnostics,
executionError,
RESULT_SCHEMA,
type Diagnostics,
type StructuredResult,
} from './results.js';
import { VERSION } from './version.js';
import type { TransferProgress } from './client.js';
import type { TraceContext } from './tracing.js';
export type Mode = 'docs' | 'read' | 'write';
export function parseMode(raw: string | undefined): Mode | undefined {
if (raw === undefined) {
return undefined;
}
const value = raw.trim().toLowerCase();
if (value === 'docs' || value === 'read' || value === 'write') {
return value;
}
throw new Error('SNOWLUMA_MCP_MODE must be one of: docs, read, write');
}
// name + every alias → action, so lookups accept aliases too.
const byName = new Map<string, CatalogAction>();
for (const a of ACTIONS) {
for (const n of [a.name, ...a.aliases]) {
byName.set(n, a);
}
}
const DOCS_TOOLS: Tool[] = [
{
name: 'list_actions',
description:
'列出 OneBot action,支持分类、读写类型、流式类型筛选和可选分页。省略分页时返回全部匹配项。',
inputSchema: {
type: 'object',
properties: {
...FILTER_PROPERTIES,
},
additionalProperties: false,
},
annotations: { readOnlyHint: true, openWorldHint: false },
},
{
name: 'get_action',
description:
'获取 action 文档、参数与返回数据 JSON Schema、约束和调用示例。示例中的标识和路径需替换;流式返回结构描述单帧数据。',
inputSchema: {
type: 'object',
properties: { name: { type: 'string', description: 'action 名(接受别名)' } },
required: ['name'],
additionalProperties: false,
},
annotations: { readOnlyHint: true, openWorldHint: false },
},
{
name: 'search_actions',
description:
'按名称、摘要或别名搜索 action。默认保留子串匹配;match=keywords 启用多关键词和常用中英文同义词,支持筛选及分页。',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: '关键字;keywords 模式可用空格分隔多个词' },
match: {
type: 'string',
enum: ['substring', 'keywords'],
description: '默认 substring',
},
...FILTER_PROPERTIES,
},
required: ['query'],
additionalProperties: false,
},
annotations: { readOnlyHint: true, openWorldHint: false },
},
{
name: 'list_categories',
description: '列出所有分类及其 action 数量。',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
annotations: { readOnlyHint: true, openWorldHint: false },
},
{
name: 'get_runtime_info',
description:
'查看当前 MCP 模式、执行能力、文件限制和目录版本。默认不访问服务端;check_connection=true 仅在执行模式主动查询登录账号、状态和版本。静态目录不代表服务端支持全部动作。',
inputSchema: {
type: 'object',
properties: {
check_connection: {
type: 'boolean',
description: '主动进行三项只读查询,默认 false',
},
},
additionalProperties: false,
},
annotations: { readOnlyHint: true, openWorldHint: true },
},
];
const EXEC_INPUT_SCHEMA = {
type: 'object' as const,
properties: {
action: {
type: 'string',
description: 'action 名(接受别名)。先用 get_action 查它的参数 inputSchema。',
},
params: { type: 'object', description: '该 action 的参数对象', additionalProperties: true },
execution: {
type: 'object',
description: 'MCP 主机侧执行选项(不会发送为 OneBot params)。自动上传本地文件时使用。',
properties: {
input_file: {
type: 'string',
description:
'MCP 主机上的文件路径。仅用于 upload_file_stream,且必须位于 SNOWLUMA_MCP_UPLOAD_ROOT。',
},
},
additionalProperties: false,
},
},
required: ['action'],
additionalProperties: false,
};
function queryTool(): Tool {
return {
name: 'query_action',
description:
'调用一个【只读】OneBot action(如 get_*/can_*)并返回完整响应。仅接受只读 action;写操作请用 invoke_action。',
inputSchema: EXEC_INPUT_SCHEMA,
annotations: { readOnlyHint: true, openWorldHint: true },
};
}
function invokeTool(): Tool {
return {
name: 'invoke_action',
description:
'调用一个 OneBot action(可产生副作用)。Stream 下载会写入 MCP 主机受控目录并只返回文件元数据;upload_file_stream 可用 execution.input_file 自动分块上传。仅在写模式可用。',
inputSchema: EXEC_INPUT_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
};
}
/** Tools visible for a given mode: docs always; +query in read/write; +invoke in write. */
export function computeTools(mode: Mode): Tool[] {
const tools: Tool[] = [...DOCS_TOOLS];
if (mode === 'read' || mode === 'write') {
tools.push(queryTool());
}
if (mode === 'write') {
tools.push(invokeTool());
}
return tools.map((tool) => ({ ...tool, outputSchema: RESULT_SCHEMA }));
}
export type ToolResult = StructuredResult;
export interface CallCtx {
mode: Mode;
client?: ActionClient;
signal?: AbortSignal;
trace?: TraceContext;
onProgress?: (progress: TransferProgress) => Promise<void>;
}
export async function callTool(
name: string,
args: Record<string, unknown>,
ctx: CallCtx,
): Promise<ToolResult> {
switch (name) {
case 'list_actions':
case 'search_actions': {
try {
return asText(discover(ACTIONS, args, name === 'search_actions'));
} catch (error) {
return asError(error instanceof Error ? error.message : String(error));
}
}
case 'get_action': {
const q = typeof args.name === 'string' ? args.name : '';
const action = byName.get(q);
if (!action) {
return asError(
`未找到 action: ${JSON.stringify(q)}。用 list_actions / search_actions 查可用项。`,
'not_found',
'使用 list_actions / search_actions 查找动作。',
);
}
return asText(action);
}
case 'list_categories':
return asText(CATEGORIES);
case 'get_runtime_info':
return runtimeInfo(args, ctx);
case 'query_action':
return execute(args, ctx, 'read');
case 'invoke_action':
return execute(args, ctx, 'write');
default:
return asError(`未知工具: ${name}`, 'not_found', '使用 tools/list 查看可用工具。');
}
}
export interface PreparedActionCall {
action: string;
params: Record<string, unknown>;
inputFile?: string;
catalog: CatalogAction;
client: ActionClient;
inputSchema: Record<string, unknown>;
}
/** The same side-effect-free gate is used before execution and input collection. */
export function prepareActionCall(
args: Record<string, unknown>,
ctx: CallCtx,
kind: 'read' | 'write',
): PreparedActionCall | ToolResult {
if (kind === 'write' && ctx.mode !== 'write') {
return asError(
'写操作未启用:需设置 SNOWLUMA_MCP_MODE=write。',
'permission',
'需要写操作时,请由操作者明确启用 write 模式。',
);
}
if (kind === 'read' && ctx.mode !== 'read' && ctx.mode !== 'write') {
return asError(
'执行未启用:需配置 SNOWLUMA_MCP_ENDPOINT。',
'permission',
'配置端点并选择 read 或 write 模式后重启 MCP。',
);
}
if (!ctx.client) {
return asError(
'未配置 OneBot 端点(SNOWLUMA_MCP_ENDPOINT),无法执行。',
'configuration',
'检查 MCP 端点配置。',
);
}
const action = typeof args.action === 'string' ? args.action : '';
const cat = byName.get(action);
if (!cat) {
return asError(
`未知 action: ${JSON.stringify(action)}。用 list_actions / search_actions 查可用项。`,
'not_found',
'使用 list_actions / search_actions 查找动作。',
);
}
if (kind === 'read' && !cat.readOnly) {
return asError(
`${action} 是写操作,不能用 query_action;请用 invoke_action(需 SNOWLUMA_MCP_MODE=write)。`,
'permission',
'需写模式和 invoke_action;不要通过只读工具绕过写操作限制。',
);
}
if (
args.params !== undefined &&
(args.params === null || typeof args.params !== 'object' || Array.isArray(args.params))
) {
return asError('params 必须是对象。');
}
const params = (args.params ?? {}) as Record<string, unknown>;
let inputFile: string | undefined;
if (args.execution !== undefined) {
if (
!args.execution ||
typeof args.execution !== 'object' ||
Array.isArray(args.execution)
) {
return asError('execution 必须是对象。');
}
const execution = args.execution as Record<string, unknown>;
const unknown = Object.keys(execution).filter((key) => key !== 'input_file');
if (unknown.length) {
return asError(`未知 MCP execution 选项: ${unknown.join(', ')}`);
}
if (execution.input_file !== undefined) {
if (typeof execution.input_file !== 'string' || !execution.input_file.trim()) {
return asError('execution.input_file 必须是非空字符串。');
}
inputFile = execution.input_file;
}
}
if (inputFile && cat.name !== 'upload_file_stream') {
return asError('execution.input_file 仅可用于 upload_file_stream。');
}
const inputSchema =
inputFile && Array.isArray(cat.inputSchema.required)
? {
...cat.inputSchema,
required: cat.inputSchema.required.filter(
(key) => !MCP_UPLOAD_OWNED_FIELDS.includes(key),
),
}
: cat.inputSchema;
return { action, params, inputFile, catalog: cat, client: ctx.client, inputSchema };
}
/** Shared execution path for query_action (read) / invoke_action (write). */
async function execute(
args: Record<string, unknown>,
ctx: CallCtx,
kind: 'read' | 'write',
): Promise<ToolResult> {
const prepared = prepareActionCall(args, ctx, kind);
if ('structuredContent' in prepared) {
return prepared;
}
const { action, params, inputFile, catalog: cat, client } = prepared;
try {
ctx.signal?.throwIfAborted();
if (cat.stream) {
const result = await client.callStream(action, params, {
...(inputFile ? { inputFile } : {}),
...(ctx.signal ? { signal: ctx.signal } : {}),
...(ctx.trace ? { trace: ctx.trace } : {}),
...(ctx.onProgress ? { onProgress: ctx.onProgress } : {}),
});
return asText(result, envelopeDiagnostics(result.terminal));
}
const result = await client.call(
action,
params,
ctx.signal || ctx.trace
? {
...(ctx.signal ? { signal: ctx.signal } : {}),
...(ctx.trace ? { trace: ctx.trace } : {}),
}
: undefined,
);
return asText(result, envelopeDiagnostics(result));
} catch (err) {
return executionError(action, err, ctx.signal);
}
}
async function runtimeInfo(args: Record<string, unknown>, ctx: CallCtx): Promise<ToolResult> {
if (args.check_connection !== undefined && typeof args.check_connection !== 'boolean') {
return asError('check_connection 必须是布尔值。');
}
const enabled = ctx.mode !== 'docs' && !!ctx.client;
if (args.check_connection && !enabled) {
return asError(
'当前模式不能检查连接:需配置端点并启用 read 或 write 模式。',
'permission',
'先用 get_runtime_info 查看本地模式,再由操作者配置连接。',
);
}
const overview = {
mode: ctx.mode,
execution_enabled: enabled,
write_enabled: enabled && ctx.mode === 'write',
catalog: {
version: VERSION,
action_count: ACTIONS.length,
source: 'bundled',
server_support: 'unknown',
},
transport: enabled ? (ctx.client?.info ?? null) : null,
connection_checked: args.check_connection === true,
};
if (!args.check_connection) {
return asText(overview);
}
const checks: Record<string, unknown> = {};
let outcome: Diagnostics['outcome'] = 'success';
for (const action of ['get_login_info', 'get_status', 'get_version_info']) {
const result = await execute({ action }, ctx, 'read');
checks[action] = result.structuredContent;
const diagnostics = result.structuredContent.diagnostics;
if (
diagnostics.outcome === 'unknown' ||
(diagnostics.outcome === 'failed' && outcome === 'success')
) {
outcome = diagnostics.outcome;
}
}
return asText(
{ ...overview, checks },
outcome === 'success'
? { outcome }
: { outcome, next_step: '查看 checks 中各项诊断;目录版本不等于服务端能力声明。' },
);
}
+110
View File
@@ -0,0 +1,110 @@
import { randomBytes } from 'node:crypto';
export interface TraceContext {
traceparent: string;
tracestate?: string;
}
function parentFields(value: unknown): { traceId: string; flags: string } | undefined {
if (typeof value !== 'string' || /[^\x21-\x7e]/.test(value)) {
return undefined;
}
const match = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(.*)$/.exec(value);
if (
!match ||
match[1] === 'ff' ||
/^0+$/.test(match[2]!) ||
/^0+$/.test(match[3]!) ||
(match[1] === '00' ? match[5] !== '' : match[5] !== '' && !match[5]!.startsWith('-'))
) {
return undefined;
}
// Participate using version 00, retaining only its defined sampled flag.
return { traceId: match[2]!, flags: (Number.parseInt(match[4]!, 16) & 1) === 1 ? '01' : '00' };
}
function traceState(value: unknown): string | undefined {
if (typeof value !== 'string' || value.length > 512 || /[^\x20-\x7e\t]/.test(value)) {
return undefined;
}
const members = value.split(',');
if (members.length > 32) {
return undefined;
}
const keys = new Set<string>();
const normalized: string[] = [];
for (const raw of members) {
const member = raw.replace(/^[ \t]+|[ \t]+$/g, '');
if (!member) {
continue;
}
const separator = member.indexOf('=');
const key = member.slice(0, separator);
const entry = member.slice(separator + 1);
if (
separator < 1 ||
!/^(?:[a-z][a-z0-9_*/-]{0,255}|[a-z0-9][a-z0-9_*/-]{0,240}@[a-z][a-z0-9_*/-]{0,13})$/.test(
key,
) ||
!/^[\x20-\x2b\x2d-\x3c\x3e-\x7e]{0,255}[\x21-\x2b\x2d-\x3c\x3e-\x7e]$/.test(entry) ||
keys.has(key)
) {
return undefined;
}
keys.add(key);
normalized.push(member);
}
return normalized.join(',');
}
function identifier(bytes: number): string {
let value: string;
do {
value = randomBytes(bytes).toString('hex');
} while (/^0+$/.test(value));
return value;
}
/** Trace metadata is advisory: invalid fields never reject a tool or enter its params. */
export function createCallTrace(meta?: Record<string, unknown>): TraceContext {
const parent = parentFields(meta?.traceparent);
if (meta?.traceparent !== undefined && !parent) {
console.error('[snowluma-mcp] trace metadata ignored: invalid traceparent');
}
let state: string | undefined;
if (meta?.tracestate !== undefined) {
if (parent) {
state = traceState(meta.tracestate);
if (state === undefined) {
console.error('[snowluma-mcp] trace metadata ignored: invalid tracestate');
}
} else {
console.error(
'[snowluma-mcp] trace metadata ignored: tracestate without valid traceparent',
);
}
}
return {
traceparent: `00-${parent?.traceId ?? identifier(16)}-${identifier(8)}-${parent?.flags ?? '00'}`,
...(state ? { tracestate: state } : {}),
};
}
+55
View File
@@ -0,0 +1,55 @@
// The MCP's own view of the OneBot action catalog. Mirrors @snowluma/onebot's
// ActionDoc, but is defined HERE so the published package does not couple to
// onebot's internals — the generated `catalog.ts` (a build-time snapshot) is the
// only contract (ADR-0005: the wire/data shape is the seam, not a shared type).
export interface CatalogParam {
name: string;
/** Display type ('uint' | 'int' | 'messageId' | 'string' | 'bool' | 'message' | 'enum' | 'X[]' | 'raw'). */
type: string;
required: boolean;
default?: unknown;
desc?: string;
values?: ReadonlyArray<string | number>;
/** Semantic role (group_id / user_id / member_id / image / face_id / …),
* orthogonal to `type`; drives smart-widget selection in the WebUI console.
* Kept as a loose string here so the package stays decoupled from onebot's
* FieldRole union. */
role?: string;
/** JSON Schema fragment for this single field. */
schema?: Record<string, unknown>;
}
export interface CatalogAction {
name: string;
aliases: string[];
category?: string;
summary?: string;
returns?: string;
/** Successful OneBot `data`, or one successful stream frame's `data`.
* Does not describe the envelope or MCP's aggregated stream result. */
returnsSchema?: Record<string, unknown>;
/** Illustrative OneBot params; identifiers/paths must be replaced before use. */
examples?: ReadonlyArray<{
summary: string;
params: Record<string, unknown>;
notes?: string;
}>;
/** True only for pure data-fetch actions (no side effects). Drives the
* read/write tool routing: read-only → query_action, else → invoke_action.
* Classified at the source spec by what the action's `run` actually does. */
readOnly: boolean;
/** True for Stream API actions (multi-frame transport). Absent ⇒ ordinary
* single-response action. */
stream?: boolean;
params: CatalogParam[];
/** Cross-field invariants, e.g. "exactly one of: message_id | (group_id+user_id)". */
invariants: string[];
/** Composed JSON Schema for the whole params object. */
inputSchema: Record<string, unknown>;
}
export interface CatalogCategory {
category: string;
count: number;
}
+6
View File
@@ -0,0 +1,6 @@
import { readFileSync } from 'node:fs';
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
version: string;
};
export const VERSION = pkg.version;
+245
View File
@@ -0,0 +1,245 @@
import { describe, expect, it, vi } from 'vitest';
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js';
import { collectActionDocs } from '@snowluma/onebot/action-docs';
import { ApiHandler, type ApiActionContext } from '@snowluma/onebot/api-handler';
import type { JsonObject, JsonValue } from '@snowluma/onebot/types';
import { ACTIONS } from '../src/generated/catalog';
import { callTool } from '../src/tools';
const validator = new AjvJsonSchemaValidator();
function resultValidator(name: string) {
const action = ACTIONS.find((entry) => entry.name === name);
if (!action?.returnsSchema) {
throw new Error(`missing result schema: ${name}`);
}
return validator.getValidator(action.returnsSchema);
}
function matchesResult(name: string, data: unknown): void {
const result = resultValidator(name)(data);
expect(result.valid, `${name}: ${result.errorMessage}`).toBe(true);
}
/** Unexpected dependencies fail rather than contacting an account or a network. */
function context(values: Record<string, unknown> = {}): ApiActionContext {
return new Proxy(values, {
get(target, key) {
if (typeof key === 'string' && Object.hasOwn(target, key)) {
return target[key];
}
throw new Error(`unstubbed action context: ${String(key)}`);
},
}) as unknown as ApiActionContext;
}
describe('bundled action contracts', () => {
it('matches the owning specs, without relying on a second hand-authored registry', () => {
expect(ACTIONS).toEqual(JSON.parse(JSON.stringify(collectActionDocs())));
});
it.each(ACTIONS)('$name publishes compilable schemas and schema-valid examples', (action) => {
expect(() => resultValidator(action.name)).not.toThrow();
const validateInput = validator.getValidator(action.inputSchema);
expect(action.examples?.length).toBeGreaterThan(0);
for (const example of action.examples ?? []) {
const result = validateInput(example.params);
expect(result.valid, `${action.name}: ${result.errorMessage}`).toBe(true);
}
});
it('exposes full contracts through get_action and aliases without executing anything', async () => {
const client = { call: vi.fn(), callStream: vi.fn() };
for (const action of ACTIONS) {
for (const name of [action.name, ...action.aliases]) {
const result = await callTool('get_action', { name }, { mode: 'docs', client });
expect(result.structuredContent.result).toEqual(action);
expect(result.content[0]).toEqual({
type: 'text',
text: JSON.stringify(action, null, 2),
});
}
}
expect(client.call).not.toHaveBeenCalled();
expect(client.callStream).not.toHaveBeenCalled();
});
it.each(['list_actions', 'search_actions'])('keeps %s compact', async (name) => {
const result = await callTool(name, { query: 'send_group_msg' }, { mode: 'docs' });
const block = result.content[0];
expect(block.type).toBe('text');
if (block.type !== 'text') {
throw new Error('expected text result');
}
const payload = JSON.parse(block.text);
expect(payload.actions.length).toBeGreaterThan(0);
for (const action of payload.actions) {
expect(action).not.toHaveProperty('examples');
expect(action).not.toHaveProperty('returnsSchema');
}
});
});
describe('result schemas against actual OneBot handlers', () => {
it('preserves signed message IDs and documents the upload-only sentinel separately', async () => {
const api = new ApiHandler(
context({
sendGroupMessage: async () => ({ messageId: -123 }),
sendForwardMsg: async () => ({ forwardId: 'example-forward' }),
}),
);
const send = await api.handle('send_group_msg', { group_id: 20001, message: 'hello' });
expect(send).toEqual({ status: 'ok', retcode: 0, data: { message_id: -123 } });
matchesResult('send_group_msg', send.data);
expect(resultValidator('send_group_msg')({ message_id: 0 }).valid).toBe(false);
for (const name of ['upload_forward_msg', 'upload_foward_msg']) {
const uploaded = await api.handle(name, {
group_id: 20001,
messages: [
{
type: 'node',
data: { user_id: 10001, nickname: 'example', content: 'hello' },
},
],
});
expect(uploaded.status).toBe('ok');
expect(uploaded.data).toMatchObject({ message_id: 0, res_id: 'example-forward' });
matchesResult(name, uploaded.data);
if (name === 'upload_forward_msg') {
expect(uploaded.data).toHaveProperty('group_id', 20001);
} else {
expect(uploaded.data).not.toHaveProperty('group_id');
}
}
});
it('distinguishes null, empty objects, and failed unsupported calls', async () => {
const api = new ApiHandler(
context({
bridge: {
apis: {
friend: { setRemark: async () => {} },
profile: { setInputStatus: async () => {} },
},
},
}),
);
const empty = await api.handle('set_friend_remark', { user_id: 10001, remark: 'example' });
expect(empty.data).toBeNull();
matchesResult('set_friend_remark', empty.data);
expect(resultValidator('set_friend_remark')({}).valid).toBe(false);
const object = await api.handle('set_input_status', { user_id: 10001 });
expect(object.data).toEqual({});
matchesResult('set_input_status', object.data);
expect(resultValidator('set_input_status')(null).valid).toBe(false);
for (const name of ['set_restart', 'create_collection', '.get_word_slices']) {
const failure = await api.handle(name, {});
expect(failure.status).toBe('failed');
expect(resultValidator(name)(failure.data).valid).toBe(false);
}
});
it('documents actual media string sizes and optional converted record fields', async () => {
const media = {
file: 'https://example.com/media',
url: 'https://example.com/media',
file_size: '42',
file_name: 'voice.amr',
};
const api = new ApiHandler(
context({
getImageInfo: async () => media,
getRecordInfo: async () => media,
bridge: {
apis: {
extras: { convertRecord: async () => ({ base64: 'aGVsbG8=', size: 5 }) },
},
},
}),
);
for (const name of ['get_image', 'get_file', 'get_record']) {
const result = await api.handle(name, { file: 'cached-media' });
expect(result.status).toBe('ok');
matchesResult(name, result.data);
expect(resultValidator(name)({ ...media, file_size: 42 }).valid).toBe(false);
}
const converted = await api.handle('get_record', {
file: 'cached-media',
out_format: 'mp3',
});
expect(converted.data).toEqual({ ...media, out_format: 'mp3', base64: 'aGVsbG8=' });
matchesResult('get_record', converted.data);
});
it('describes get_msg data without the event envelope for both conversation types', async () => {
for (const messageType of ['group', 'private']) {
const event: JsonObject = {
post_type: 'message',
self_id: 10002,
message_type: messageType,
message_id: -123,
user_id: 10001,
time: 1700000000,
message: [{ type: 'text', data: { text: 'hello' } }],
raw_message: 'hello',
...(messageType === 'group' ? { group_id: 20001 } : { target_id: 10002 }),
};
const api = new ApiHandler(
context({
getMessage: async () => event,
refreshMessageMedia: async () => {},
}),
);
const result = await api.handle('get_msg', { message_id: -123 });
expect(result.status).toBe('ok');
expect(result.data).toMatchObject({ real_id: -123, message_id: -123 });
expect(result.data).not.toHaveProperty('post_type');
expect(result.data).not.toHaveProperty('self_id');
matchesResult('get_msg', result.data);
}
});
it('describes successful stream frame data, not MCP aggregate results or failed frames', async () => {
const api = new ApiHandler(context());
const frames: JsonValue[] = [];
const response = await api.handle(
'test_download_stream',
{ error: false },
{
send: async (frame) => {
frames.push(frame);
},
},
);
expect(frames).toHaveLength(10);
expect(response.status).toBe('ok');
for (const frame of [...frames, response.data]) {
matchesResult('test_download_stream', frame);
}
expect(
resultValidator('test_download_stream')({ frame_count: 11, frames, terminal: response })
.valid,
).toBe(false);
expect(
resultValidator('test_download_stream')({ type: 'error', data_type: 'error' }).valid,
).toBe(false);
const failure = await api.handle('test_download_stream', { error: true });
expect(failure.status).toBe('failed');
});
});
+275
View File
@@ -0,0 +1,275 @@
import { describe, expect, it, vi } from 'vitest';
import { callTool, computeTools } from '../src/tools';
import { ACTIONS } from '../src/generated/catalog';
import type { ActionClient, OneBotEnvelope } from '../src/client';
import { ActionCallError } from '../src/results';
const docs = { mode: 'docs' as const };
function client(
reply: OneBotEnvelope = { status: 'ok', retcode: 0, data: { message_id: -123 } },
): ActionClient {
return {
call: vi.fn(async () => reply),
callStream: vi.fn(async () => ({ frame_count: 1, frames: [], terminal: reply })),
};
}
function text(result: Awaited<ReturnType<typeof callTool>>) {
expect(result.content[0].type).toBe('text');
return result.content[0].type === 'text' ? result.content[0].text : '';
}
describe('additive structured results', () => {
it.each(['list_actions', 'list_categories', 'get_action', 'search_actions'])(
'keeps the original text payload for %s',
async (name) => {
const result = await callTool(name, { name: 'get_status', query: 'get_status' }, docs);
expect(result.structuredContent.result).toEqual(JSON.parse(text(result)));
expect(result.structuredContent.diagnostics).toEqual({ outcome: 'success' });
expect(
computeTools('docs').find((tool) => tool.name === name)?.outputSchema,
).toBeDefined();
},
);
it.each([
{ status: 'ok', retcode: 0, data: { message_id: -123 } },
{ status: 'failed', retcode: 1404, wording: 'not found' },
{ status: 'async', retcode: 1 },
])('preserves the OneBot envelope and isError contract ($status)', async (reply) => {
const result = await callTool(
'invoke_action',
{ action: 'send_group_msg' },
{ mode: 'write', client: client(reply) },
);
expect(text(result)).toBe(JSON.stringify(reply, null, 2));
expect(result.isError).toBeUndefined();
expect(result.structuredContent.result).toEqual(reply);
expect(result.structuredContent.diagnostics.outcome).toBe(
reply.status === 'ok' ? 'success' : reply.status === 'failed' ? 'failed' : 'unknown',
);
});
it('reports permission failures without contacting OneBot', async () => {
const onebot = client();
const result = await callTool(
'query_action',
{ action: 'send_group_msg' },
{ mode: 'read', client: onebot },
);
expect(result.isError).toBe(true);
expect(result.structuredContent.diagnostics).toMatchObject({
outcome: 'failed',
kind: 'permission',
});
expect(onebot.call).not.toHaveBeenCalled();
});
it.each(['timeout', 'cancelled', 'authentication', 'transport', 'invalid_response'] as const)(
'makes %s observable without claiming a write was rolled back',
async (kind) => {
const onebot = client();
onebot.call = vi.fn(async () => {
throw new ActionCallError('request failed', kind);
});
const result = await callTool(
'invoke_action',
{ action: 'send_group_msg' },
{ mode: 'write', client: onebot },
);
expect(result.isError).toBe(true);
expect(result.structuredContent.diagnostics).toMatchObject({
outcome: 'unknown',
kind,
});
expect(result.structuredContent.diagnostics.next_step).toContain('不代表撤销');
expect(onebot.call).toHaveBeenCalledTimes(1);
},
);
it('does not report an incomplete response as successful', async () => {
const onebot = client();
onebot.call = async () => JSON.parse('{"data":null}');
const result = await callTool(
'query_action',
{ action: 'get_status' },
{ mode: 'read', client: onebot },
);
expect(result.structuredContent.diagnostics).toMatchObject({
outcome: 'unknown',
kind: 'invalid_response',
});
expect(text(result)).toBe('{\n "data": null\n}');
});
it('keeps malformed peer fields in the original payload without breaking the diagnostics schema', async () => {
const onebot = client();
onebot.call = async () =>
JSON.parse('{"status":"failed","retcode":1400,"wording":{"unexpected":true}}');
const result = await callTool(
'query_action',
{ action: 'get_status' },
{ mode: 'read', client: onebot },
);
expect(result.structuredContent.diagnostics.message).toBe('retcode=1400');
expect(JSON.parse(text(result)).wording).toEqual({ unexpected: true });
});
});
describe('optional discovery controls', () => {
it('keeps legacy matching, ordering and response fields by default', async () => {
const result = await callTool('search_actions', { query: '群 禁言' }, docs);
expect(JSON.parse(text(result))).toEqual({ count: 0, actions: [] });
const all = JSON.parse(text(await callTool('list_actions', {}, docs)));
expect(Object.keys(all)).toEqual(['count', 'actions']);
expect(all.actions.map((action: { name: string }) => action.name)).toEqual(
ACTIONS.map((action) => action.name),
);
});
it.each([
['群 禁言', 'set_group_ban'],
['历史消息', 'get_group_msg_history'],
['私聊 history', 'get_friend_msg_history'],
['group mute', 'set_group_ban'],
])('finds %s with optional keyword matching', async (query, expected) => {
const data = JSON.parse(
text(await callTool('search_actions', { query, match: 'keywords' }, docs)),
);
expect(data.actions.map((action: { name: string }) => action.name)).toContain(expected);
});
it('ranks an exact alias first and combines filters', async () => {
const exact = JSON.parse(
text(
await callTool('search_actions', { query: 'nc_get_rkey', match: 'keywords' }, docs),
),
);
expect(exact.actions[0].name).toBe('get_rkey');
const filtered = JSON.parse(
text(
await callTool(
'search_actions',
{
query: '群',
match: 'keywords',
category: '群管理',
read_only: false,
stream: false,
},
docs,
),
),
);
expect(filtered.count).toBeGreaterThan(0);
expect(
filtered.actions.every(
(action: { category: string; readOnly: boolean; stream: boolean }) =>
action.category === '群管理' && !action.readOnly && !action.stream,
),
).toBe(true);
});
it('paginates deterministically with explicit totals and termination', async () => {
const names: string[] = [];
let offset: number | null = 0;
while (offset !== null) {
const data = JSON.parse(
text(await callTool('list_actions', { limit: 37, offset }, docs)),
);
expect(data.total).toBe(ACTIONS.length);
expect(data.count).toBe(data.actions.length);
names.push(...data.actions.map((action: { name: string }) => action.name));
offset = data.next_offset;
}
expect(names).toEqual(ACTIONS.map((action) => action.name));
const beyond = JSON.parse(text(await callTool('list_actions', { offset: 9999 }, docs)));
expect(beyond).toEqual({ count: 0, actions: [], total: ACTIONS.length, next_offset: null });
});
it.each([
{ limit: 0 },
{ limit: 201 },
{ offset: -1 },
{ offset: 0.5 },
{ stream: 'true' },
{ read_only: 1 },
{ match: 'typo' },
])('rejects invalid new options (%j)', async (options) => {
const result = await callTool('search_actions', { query: '', ...options }, docs);
expect(result.isError).toBe(true);
expect(result.structuredContent.diagnostics.kind).toBe('invalid_arguments');
});
});
describe('runtime overview and caller cancellation', () => {
it('does not contact OneBot for a local overview, even if a client exists in docs mode', async () => {
const onebot = client();
const result = await callTool('get_runtime_info', {}, { ...docs, client: onebot });
expect(result.structuredContent.result).toMatchObject({
mode: 'docs',
execution_enabled: false,
write_enabled: false,
transport: null,
connection_checked: false,
catalog: { action_count: ACTIONS.length, server_support: 'unknown' },
});
expect(onebot.call).not.toHaveBeenCalled();
const denied = await callTool(
'get_runtime_info',
{ check_connection: true },
{ ...docs, client: onebot },
);
expect(denied.isError).toBe(true);
expect(onebot.call).not.toHaveBeenCalled();
});
it('only performs the three explicit read-only checks and preserves individual failures', async () => {
const onebot = client();
const controller = new AbortController();
onebot.call = vi.fn(async (action, _params, options) => {
expect(options?.signal).toBe(controller.signal);
return action === 'get_status'
? { status: 'failed', retcode: 1401, wording: 'unauthorized' }
: { status: 'ok', retcode: 0, data: { action } };
});
const result = await callTool(
'get_runtime_info',
{ check_connection: true },
{ mode: 'read', client: onebot, signal: controller.signal },
);
expect(vi.mocked(onebot.call).mock.calls.map(([action]) => action)).toEqual([
'get_login_info',
'get_status',
'get_version_info',
]);
expect(result.structuredContent.diagnostics.outcome).toBe('failed');
expect(result.structuredContent.result).toMatchObject({
checks: {
get_status: { result: { retcode: 1401 }, diagnostics: { outcome: 'failed' } },
},
});
});
it('forwards ordinary request cancellation and does not start pre-cancelled calls', async () => {
const controller = new AbortController();
const onebot = client();
await callTool(
'query_action',
{ action: 'get_status' },
{ mode: 'read', client: onebot, signal: controller.signal },
);
expect(onebot.call).toHaveBeenCalledWith('get_status', {}, { signal: controller.signal });
controller.abort();
const result = await callTool(
'query_action',
{ action: 'get_status' },
{ mode: 'read', client: onebot, signal: controller.signal },
);
expect(onebot.call).toHaveBeenCalledTimes(1);
expect(result.structuredContent.diagnostics.kind).toBe('cancelled');
});
});
+124
View File
@@ -0,0 +1,124 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createServer, type Server } from 'node:http';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { makeHttpClient } from '../src/client';
const dirs: string[] = [];
const servers: Server[] = [];
afterEach(async () => {
for (const server of servers.splice(0)) {
server.closeAllConnections();
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
for (const dir of dirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function directory() {
const dir = mkdtempSync(join(tmpdir(), 'mcp-cancel-'));
dirs.push(dir);
return dir;
}
describe('ordinary HTTP cancellation', () => {
it.each([false, true])(
'aborts the actual HTTP request (headers received: %s)',
async (sendHeaders) => {
const started = Promise.withResolvers<void>();
const disconnected = Promise.withResolvers<void>();
let count = 0;
const server = createServer((_request, response) => {
count++;
response.on('close', () => disconnected.resolve());
if (sendHeaders) {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.write('{"status":');
}
started.resolve();
});
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('missing test server port');
}
const client = makeHttpClient({
endpoint: `http://127.0.0.1:${address.port}`,
streamDir: directory(),
timeoutMs: 5000,
});
const controller = new AbortController();
const outcome = client
.call('get_status', {}, { signal: controller.signal })
.catch((error) => error);
await started.promise;
controller.abort(new Error('caller cancelled'));
expect(await outcome).toMatchObject({ kind: 'cancelled' });
await disconnected.promise;
expect(count).toBe(1);
},
);
it('does not issue an HTTP request when already cancelled', async () => {
const fetcher = vi.fn<typeof fetch>();
const client = makeHttpClient({
endpoint: 'http://unused.invalid',
streamDir: directory(),
fetch: fetcher,
});
await expect(
client.call('get_status', {}, { signal: AbortSignal.abort() }),
).rejects.toMatchObject({ kind: 'cancelled' });
expect(fetcher).not.toHaveBeenCalled();
});
it('retains the ordinary timeout and exposes its cause', async () => {
const fetcher: typeof fetch = vi.fn(
async (_input, init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), {
once: true,
});
}),
);
const client = makeHttpClient({
endpoint: 'http://unused.invalid',
streamDir: directory(),
fetch: fetcher,
timeoutMs: 20,
});
await expect(client.call('get_status', {})).rejects.toMatchObject({ kind: 'timeout' });
expect(fetcher).toHaveBeenCalledTimes(1);
});
it('reports resolved limits and paths without endpoint credentials', () => {
const dir = directory();
const client = makeHttpClient({
endpoint: 'https://user:secret@example.invalid/path?token=secret',
accessToken: 'secret',
streamDir: dir,
uploadRoot: dir,
maxStreamBytes: 123,
timeoutMs: 456,
});
expect(client.info).toMatchObject({
timeout_ms: 456,
max_stream_bytes: 123,
download_quota_bytes: 246,
max_active_streams: 4,
upload_enabled: true,
});
expect(client.info?.upload_root).toBe(client.info?.download_directory);
expect(JSON.stringify(client.info)).not.toMatch(/secret|example\.invalid/);
});
});
File diff suppressed because it is too large. Load diff
+585
View File
@@ -0,0 +1,585 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpHandler, Server, type ClientCapabilities } from '@modelcontextprotocol/server';
import { Client as LegacyClient } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import {
createInputCompletion,
inputCompletionBaseTool,
inputCompletionTools,
isInputCompletionTool,
} from '../src/elicitation';
import { asError, asText } from '../src/results';
import { computeTools, prepareActionCall, type Mode } from '../src/tools';
import type { ActionClient } from '../src/client';
import { createMcpServer } from '../src/server-factory';
const cleanup: Array<() => void | Promise<void>> = [];
const completeCapabilities: ClientCapabilities = { elicitation: { form: {} } };
const ORIGINAL = { action: 'send_private_msg', params: { message: 'private original message' } };
afterEach(async () => {
for (const close of cleanup.splice(0).reverse()) {
await close();
}
vi.useRealTimers();
vi.restoreAllMocks();
});
function harness(options: Parameters<typeof createInputCompletion>[0] = {}, mode: Mode = 'write') {
const completion = createInputCompletion(options);
const execute = vi.fn(async (args: Record<string, unknown>) => asText(args.params));
const client: ActionClient = {
call: vi.fn(async () => ({ status: 'ok', retcode: 0 })),
callStream: vi.fn(async () => ({
frame_count: 1,
frames: [],
terminal: { status: 'ok', retcode: 0 },
})),
};
const preflight = vi.fn((args: Record<string, unknown>, kind: 'read' | 'write') => {
const result = prepareActionCall(args, { mode, client }, kind);
return 'structuredContent' in result
? result
: {
action: result.action,
params: result.params,
inputSchema: result.inputSchema,
};
});
const factory = () => {
const server = new Server(
{ name: 'input-test', version: '1' },
{
capabilities: { tools: {} },
requestState: { verify: completion.verifyRequestState },
},
);
server.setRequestHandler('tools/list', async () => ({
tools: inputCompletionTools(computeTools('write')),
}));
server.setRequestHandler('tools/call', (request, context) => {
if (!isInputCompletionTool(request.params.name)) {
return asError('unknown tool');
}
const name = request.params.name;
return completion.handle(name, request.params.arguments ?? {}, context, {
capabilities:
context.mcpReq.envelope?.['io.modelcontextprotocol/clientCapabilities'] ??
server.getClientCapabilities(),
preflight: (args) =>
preflight(
args,
inputCompletionBaseTool(name) === 'query_action' ? 'read' : 'write',
),
execute,
});
});
return server;
};
const handler = createMcpHandler(factory);
cleanup.push(
() => completion.close(),
() => handler.close(),
);
async function raw(
arguments_: Record<string, unknown> = ORIGINAL,
retry: Record<string, unknown> = {},
capabilities: ClientCapabilities = completeCapabilities,
name = 'invoke_action_with_input',
token?: string,
) {
const response = await handler.fetch(
new Request('http://input.invalid/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'Mcp-Method': 'tools/call',
'Mcp-Name': name,
'Mcp-Protocol-Version': '2026-07-28',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name,
arguments: arguments_,
...retry,
_meta: {
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
'io.modelcontextprotocol/clientCapabilities': capabilities,
'io.modelcontextprotocol/clientInfo': {
name: 'input-test',
version: '1',
},
},
},
}),
}),
token ? { authInfo: { token, clientId: 'test-app', scopes: [] } } : undefined,
);
return response.json();
}
return { completion, handler, factory, execute, preflight, raw };
}
function answer(requestState: string, content: unknown = { params_json: '{"user_id":123}' }) {
return { requestState, inputResponses: { missing_parameters: { action: 'accept', content } } };
}
describe('optional action input completion', () => {
it('integrates with the production factory and preserves the original action result contract', async () => {
const completion = createInputCompletion();
const onebot: ActionClient = {
call: vi.fn(async () => ({ status: 'ok', retcode: 0, data: { message_id: -42 } })),
callStream: vi.fn(),
};
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
const handler = createMcpHandler(() =>
createMcpServer({ mode: 'write', client: onebot, inputCompletion: completion }),
);
const client = new Client(
{ name: 'production-input-test', version: '1' },
{
capabilities: completeCapabilities,
versionNegotiation: { mode: { pin: '2026-07-28' } },
},
);
client.setRequestHandler('elicitation/create', async () => {
expect(onebot.call).not.toHaveBeenCalled();
return { action: 'accept', content: { params_json: '{"user_id":123}' } };
});
cleanup.push(
() => completion.close(),
() => handler.close(),
() => client.close(),
);
await client.connect(
new StreamableHTTPClientTransport(new URL('http://input.invalid/mcp'), {
fetch: (input, init) => handler.fetch(new Request(input, init)),
}),
);
const result = await client.callTool({
name: 'invoke_action_with_input',
arguments: ORIGINAL,
});
expect(result.structuredContent).toEqual({
result: { status: 'ok', retcode: 0, data: { message_id: -42 } },
diagnostics: { outcome: 'success' },
});
expect(onebot.call).toHaveBeenCalledExactlyOnceWith(
'send_private_msg',
{ user_id: 123, message: ORIGINAL.params.message },
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(JSON.stringify(log.mock.calls)).not.toContain(ORIGINAL.params.message);
expect(log.mock.calls.map(([, value]) => JSON.parse(String(value)).event)).toEqual([
'call_started',
'call_input_required',
'call_started',
'call_finished',
]);
});
it.each(['docs', 'read'] as const)(
'keeps the production factory mode gate before input collection in %s mode',
async (mode) => {
const onebot: ActionClient = { call: vi.fn(), callStream: vi.fn() };
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
const handler = createMcpHandler(() => createMcpServer({ mode, client: onebot }));
const client = new Client(
{ name: 'permission-input-test', version: '1' },
{
capabilities: completeCapabilities,
versionNegotiation: { mode: { pin: '2026-07-28' } },
},
);
const ask = vi.fn(async () => ({
action: 'accept' as const,
content: { params_json: '{"user_id":123}' },
}));
client.setRequestHandler('elicitation/create', ask);
cleanup.push(
() => handler.close(),
() => client.close(),
);
await client.connect(
new StreamableHTTPClientTransport(new URL('http://input.invalid/mcp'), {
fetch: (input, init) => handler.fetch(new Request(input, init)),
}),
);
const result = await client.callTool({
name: 'invoke_action_with_input',
arguments: ORIGINAL,
});
expect(result.structuredContent).toMatchObject({ diagnostics: { kind: 'permission' } });
expect(ask).not.toHaveBeenCalled();
expect(onebot.call).not.toHaveBeenCalled();
expect(log.mock.calls.map(([, value]) => JSON.parse(String(value)).event)).toEqual([
'call_started',
'call_finished',
]);
},
);
it('inherits tool visibility and existing schemas without altering the original list', () => {
expect(inputCompletionTools(computeTools('docs'))).toEqual([]);
expect(inputCompletionTools(computeTools('read')).map((tool) => tool.name)).toEqual([
'query_action_with_input',
]);
const tools = computeTools('write');
expect(inputCompletionTools(tools).map((tool) => tool.name)).toEqual([
'query_action_with_input',
'invoke_action_with_input',
]);
expect(tools.some((tool) => tool.name.endsWith('_with_input'))).toBe(false);
expect(inputCompletionTools(tools)[1].inputSchema).toEqual(
tools.find((tool) => tool.name === 'invoke_action')?.inputSchema,
);
});
it('asks before executing, never discloses existing values, and merges only validated missing fields', async () => {
const { raw, execute } = harness();
const first = await raw();
expect(first.result.resultType).toBe('input_required');
expect(execute).not.toHaveBeenCalled();
expect(first.result.inputRequests.missing_parameters).toMatchObject({
method: 'elicitation/create',
});
expect(JSON.stringify(first)).not.toContain(ORIGINAL.params.message);
const decodedToken = Buffer.from(
first.result.requestState.split('.')[1],
'base64url',
).toString();
expect(decodedToken).not.toContain(ORIGINAL.params.message);
const result = await raw(ORIGINAL, answer(first.result.requestState));
expect(result.result).toMatchObject({
resultType: 'complete',
structuredContent: { result: { user_id: 123, message: ORIGINAL.params.message } },
});
expect(execute).toHaveBeenCalledExactlyOnceWith({
action: ORIGINAL.action,
params: { ...ORIGINAL.params, user_id: 123 },
});
});
it('executes complete arguments without requiring elicitation capability or prompting', async () => {
const { raw, execute } = harness();
const result = await raw({ action: 'get_msg', params: { message_id: -123 } }, {}, {});
expect(result.result.structuredContent.result).toEqual({ message_id: -123 });
expect(execute).toHaveBeenCalledOnce();
});
it('honors permission failure before asking for input or executing', async () => {
const { raw, execute, preflight } = harness();
preflight.mockReturnValue(asError('write mode required', 'permission'));
const result = await raw();
expect(result.result.structuredContent.diagnostics.kind).toBe('permission');
expect(result.result.inputRequests).toBeUndefined();
expect(execute).not.toHaveBeenCalled();
});
it.each(['docs', 'read'] as const)(
'enforces actual write permission in %s mode',
async (mode) => {
const { raw, execute } = harness({}, mode);
const result = await raw();
expect(result.result.structuredContent.diagnostics.kind).toBe('permission');
expect(result.result.inputRequests).toBeUndefined();
expect(execute).not.toHaveBeenCalled();
},
);
it('does not turn a write action into a read action through the input helper', async () => {
const { raw, execute } = harness();
const result = await raw(ORIGINAL, {}, completeCapabilities, 'query_action_with_input');
expect(result.result.structuredContent.diagnostics.kind).toBe('permission');
expect(result.result.inputRequests).toBeUndefined();
expect(execute).not.toHaveBeenCalled();
});
it('fails visibly when the client has no form capability', async () => {
const { raw, execute } = harness();
for (const capabilities of [{}, { elicitation: { url: {} } }]) {
const result = await raw(ORIGINAL, {}, capabilities);
expect(result.result.structuredContent.diagnostics.kind).toBe('configuration');
}
expect(execute).not.toHaveBeenCalled();
});
it.each(['decline', 'cancel'])(
'stops on %s instead of repeatedly prompting',
async (action) => {
const { raw, execute } = harness();
const first = await raw();
const result = await raw(ORIGINAL, {
requestState: first.result.requestState,
inputResponses: { missing_parameters: { action } },
});
expect(result.result.structuredContent.diagnostics.kind).toBe('cancelled');
expect(result.result.inputRequests).toBeUndefined();
expect(execute).not.toHaveBeenCalled();
},
);
it.each([
{ params_json: '{' },
{ params_json: '{}' },
{ params_json: '{"user_id":0}' },
{ params_json: '{"user_id":"123"}' },
{ params_json: '{"user_id":123,"message":"replacement"}' },
{ params_json: '{"user_id":123,"auto_escape":true}' },
{ params_json: '[]' },
{ params_json: '{"user_id":123}', unexpected: 'value' },
])('rejects invalid or extraneous answers without executing: %j', async (content) => {
const { raw, execute } = harness();
const first = await raw();
const result = await raw(ORIGINAL, answer(first.result.requestState, content));
expect(result.result.isError).toBe(true);
expect(execute).not.toHaveBeenCalled();
});
it('accepts complex missing message data without guessing or normalizing it', async () => {
const { raw, execute } = harness();
const args = { action: 'send_private_msg', params: { user_id: 123 } };
const message = [
{ type: 'reply', data: { id: -42 } },
{ type: 'text', data: { text: 'hello' } },
];
const first = await raw(args);
await raw(
args,
answer(first.result.requestState, { params_json: JSON.stringify({ message }) }),
);
expect(execute).toHaveBeenCalledExactlyOnceWith({
action: args.action,
params: { user_id: 123, message },
});
});
it('rejects tampered, mismatched and state-less retries', async () => {
const { raw, execute } = harness();
const first = await raw();
const tampered = await raw(ORIGINAL, answer(`${first.result.requestState}x`));
expect(tampered.error.code).toBe(-32602);
const changed = await raw(
{ ...ORIGINAL, params: { message: 'different message' } },
answer(first.result.requestState),
);
expect(changed.result.isError).toBe(true);
const crossed = await raw(
ORIGINAL,
answer(first.result.requestState),
completeCapabilities,
'query_action_with_input',
);
expect(crossed.result.isError).toBe(true);
const noState = await raw(ORIGINAL, { inputResponses: answer('').inputResponses });
expect(noState.result.isError).toBe(true);
expect(execute).not.toHaveBeenCalled();
});
it('binds retries to the validated caller, including users sharing the same OAuth client', async () => {
const { raw, execute } = harness();
const first = await raw(
ORIGINAL,
{},
completeCapabilities,
'invoke_action_with_input',
'user-a-token',
);
const result = await raw(
ORIGINAL,
answer(first.result.requestState),
completeCapabilities,
'invoke_action_with_input',
'user-b-token',
);
expect(result.error.code).toBe(-32602);
expect(execute).not.toHaveBeenCalled();
});
it('allows exactly one concurrent completion and refuses later replay', async () => {
const { raw, execute } = harness();
const first = await raw();
const results = await Promise.all([
raw(ORIGINAL, answer(first.result.requestState)),
raw(ORIGINAL, answer(first.result.requestState)),
]);
expect(execute).toHaveBeenCalledOnce();
expect(results.filter((result) => result.result.isError)).toHaveLength(1);
expect((await raw(ORIGINAL, answer(first.result.requestState))).result.isError).toBe(true);
expect(execute).toHaveBeenCalledOnce();
});
it('expires pending input and bounds outstanding prompts without evicting live ones', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const { raw, execute } = harness({ ttlSeconds: 1, maxPending: 1 });
const first = await raw();
expect((await raw()).result.structuredContent.diagnostics.kind).toBe('execution');
vi.setSystemTime(Date.now() + 2_000);
expect((await raw(ORIGINAL, answer(first.result.requestState))).error.code).toBe(-32602);
expect((await raw()).result.resultType).toBe('input_required');
expect(execute).not.toHaveBeenCalled();
});
it('does not resume an unfinished prompt after closing the runtime', async () => {
const { raw, execute, completion } = harness();
const first = await raw();
completion.close();
expect((await raw(ORIGINAL, answer(first.result.requestState))).error.code).toBe(-32602);
expect(execute).not.toHaveBeenCalled();
});
it('lets a modern client drive the standard MRTR cycle', async () => {
const { handler, execute } = harness();
const client = new Client(
{ name: 'modern-input-test', version: '1' },
{
capabilities: completeCapabilities,
versionNegotiation: { mode: { pin: '2026-07-28' } },
},
);
client.setRequestHandler('elicitation/create', async () => ({
action: 'accept',
content: { params_json: '{"user_id":123}' },
}));
cleanup.push(() => client.close());
await client.connect(
new StreamableHTTPClientTransport(new URL('http://input.invalid/mcp'), {
fetch: (input, init) => handler.fetch(new Request(input, init)),
}),
);
const result = await client.callTool({
name: 'invoke_action_with_input',
arguments: ORIGINAL,
});
expect(result.structuredContent).toMatchObject({
result: { user_id: 123, message: ORIGINAL.params.message },
});
expect(execute).toHaveBeenCalledOnce();
});
it('does not execute when the caller aborts while the input form is open', async () => {
const { handler, execute } = harness();
const entered = Promise.withResolvers<void>();
const inputAborted = Promise.withResolvers<void>();
const client = new Client(
{ name: 'cancel-input-test', version: '1' },
{
capabilities: completeCapabilities,
versionNegotiation: { mode: { pin: '2026-07-28' } },
},
);
client.setRequestHandler('elicitation/create', async (_request, context) => {
context.mcpReq.signal.addEventListener('abort', () => inputAborted.resolve(), {
once: true,
});
entered.resolve();
await inputAborted.promise;
return { action: 'cancel' };
});
cleanup.push(() => client.close());
await client.connect(
new StreamableHTTPClientTransport(new URL('http://input.invalid/mcp'), {
fetch: (input, init) => handler.fetch(new Request(input, init)),
}),
);
const controller = new AbortController();
const result = client
.callTool(
{ name: 'invoke_action_with_input', arguments: ORIGINAL },
{ signal: controller.signal },
)
.catch((error: unknown) => error);
await entered.promise;
controller.abort(new Error('cancel test'));
await inputAborted.promise;
expect(await result).toBeInstanceOf(Error);
expect(execute).not.toHaveBeenCalled();
});
it('integrates the production factory through the legacy elicitation shim', async () => {
const onebot: ActionClient = {
call: vi.fn(async () => ({ status: 'ok', retcode: 0, data: { message_id: -42 } })),
callStream: vi.fn(),
};
vi.spyOn(console, 'error').mockImplementation(() => {});
const server = createMcpServer({ mode: 'write', client: onebot });
const client = new LegacyClient(
{ name: 'legacy-input-test', version: '1' },
{ capabilities: { elicitation: {} } },
);
client.setRequestHandler(ElicitRequestSchema, async () => {
expect(onebot.call).not.toHaveBeenCalled();
return { action: 'accept', content: { params_json: '{"user_id":123}' } };
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
cleanup.push(
() => server.close(),
() => client.close(),
);
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({
name: 'invoke_action_with_input',
arguments: ORIGINAL,
});
expect(result.structuredContent).toMatchObject({
result: { status: 'ok', retcode: 0, data: { message_id: -42 } },
});
expect(result.resultType).toBeUndefined();
expect(onebot.call).toHaveBeenCalledExactlyOnceWith(
'send_private_msg',
{ user_id: 123, message: ORIGINAL.params.message },
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it('keeps automatic local uploads complete without asking for runtime-owned transfer fields', async () => {
const streamResult = {
frame_count: 1,
frames: [],
terminal: { status: 'ok', retcode: 0, data: { file: 'uploaded-file' } },
};
const onebot: ActionClient = { call: vi.fn(), callStream: vi.fn(async () => streamResult) };
vi.spyOn(console, 'error').mockImplementation(() => {});
const server = createMcpServer({ mode: 'write', client: onebot });
const client = new LegacyClient({ name: 'auto-upload-input-test', version: '1' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
cleanup.push(
() => server.close(),
() => client.close(),
);
await server.connect(serverTransport);
await client.connect(clientTransport);
const args = {
action: 'upload_file_stream',
execution: { input_file: '/fixture/media.bin' },
};
const original = await client.callTool({ name: 'invoke_action', arguments: args });
const extended = await client.callTool({
name: 'invoke_action_with_input',
arguments: args,
});
expect(extended.structuredContent).toEqual(original.structuredContent);
expect(extended.isError).toBeUndefined();
expect(onebot.callStream).toHaveBeenCalledTimes(2);
expect(onebot.callStream).toHaveBeenLastCalledWith(
'upload_file_stream',
{},
expect.objectContaining({ inputFile: args.execution.input_file }),
);
});
});
+474
View File
@@ -0,0 +1,474 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { App } from '@modelcontextprotocol/ext-apps';
import { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';
import { InMemoryTransport } from '@modelcontextprotocol/client';
import { Client as LegacyClient } from '@modelcontextprotocol/sdk/client/index.js';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { createMcpServer } from '../src/server-factory.js';
import type { ActionClient } from '../src/client.js';
import type { Mode } from '../src/tools.js';
import {
MESSAGE_APP_MIME_TYPE,
MESSAGE_APP_URI,
messageAppResources,
messagePreviewTool,
previewMessage,
readMessageAppResource,
supportsMessageApp,
} from '../src/message-app.js';
import { mountMessagePreview, type PreviewApp } from '../apps/preview-view.js';
import { buildMessageApp } from '../scripts/build-message-app.mjs';
const temporary: string[] = [];
const close: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const cleanup of close.splice(0).reverse()) {
await cleanup();
}
await Promise.all(
temporary.splice(0).map((path) => rm(path, { recursive: true, force: true })),
);
});
const draft = { message_type: 'group', target_id: 12345, text: '你好' };
function fakeOneBot(): ActionClient {
return {
call: vi.fn(async () => ({ status: 'ok', retcode: 0, data: { message_id: -42 } })),
callStream: vi.fn(async () => ({
frame_count: 0,
frames: [],
terminal: { status: 'ok', retcode: 0 },
})),
};
}
const appCapabilities = {
extensions: { 'io.modelcontextprotocol/ui': { mimeTypes: [MESSAGE_APP_MIME_TYPE] } },
};
describe('message preview server integration', () => {
it.each(['docs', 'read', 'write'] as Mode[])(
'keeps legacy preview side-effect free and applies %s mode and negotiated UI capability',
async (mode) => {
for (const ui of [false, true]) {
const onebot = fakeOneBot();
const server = createMcpServer({ mode, client: onebot });
const client = new LegacyClient(
{ name: 'preview-legacy-test', version: '1.0.0' },
{ capabilities: ui ? appCapabilities : {} },
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
close.push(
() => server.close(),
() => client.close(),
);
await server.connect(serverTransport);
await client.connect(clientTransport);
const tools = await client.listTools();
const preview = tools.tools.find((tool) => tool.name === 'preview_message');
expect(preview).toBeDefined();
expect(preview?._meta).toEqual(
ui
? { ui: { resourceUri: MESSAGE_APP_URI, visibility: ['model', 'app'] } }
: undefined,
);
const resources = await client.listResources();
expect(
resources.resources.some((resource) => resource.uri === MESSAGE_APP_URI),
).toBe(ui);
const result = await client.callTool({ name: 'preview_message', arguments: draft });
expect(result.isError).toBeUndefined();
expect(result.structuredContent).toMatchObject({
result: { can_send: mode === 'write', draft },
});
expect(onebot.call).not.toHaveBeenCalled();
expect(onebot.callStream).not.toHaveBeenCalled();
if (mode !== 'write') {
expect(
await client.callTool({
name: 'invoke_action',
arguments: {
action: 'send_group_msg',
params: { group_id: 12345, message: '你好' },
},
}),
).toMatchObject({ isError: true });
expect(onebot.call).not.toHaveBeenCalled();
}
}
},
);
it('negotiates UI metadata independently for each modern request and never sends a preview', async () => {
const onebot = fakeOneBot();
const handler = createMcpHandler(() => createMcpServer({ mode: 'write', client: onebot }));
close.push(() => handler.close());
const request = async (method: string, params: Record<string, unknown>, ui: boolean) => {
const response = await handler.fetch(
new Request('http://mcp.invalid/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'Mcp-Method': method,
'Mcp-Protocol-Version': '2026-07-28',
...(typeof params.name === 'string' ? { 'Mcp-Name': params.name } : {}),
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method,
params: {
...params,
_meta: {
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
'io.modelcontextprotocol/clientCapabilities': ui
? appCapabilities
: {},
'io.modelcontextprotocol/clientInfo': {
name: 'preview-modern-test',
version: '1.0.0',
},
},
},
}),
}),
);
expect(response.status).toBe(200);
return await response.json();
};
for (const ui of [true, false, true]) {
const tools = await request('tools/list', {}, ui);
expect(
tools.result.tools.find((tool: { name: string }) => tool.name === 'preview_message')
._meta,
).toEqual(
ui
? { ui: { resourceUri: MESSAGE_APP_URI, visibility: ['model', 'app'] } }
: undefined,
);
const resources = await request('resources/list', {}, ui);
expect(
resources.result.resources.some(
(resource: { uri: string }) => resource.uri === MESSAGE_APP_URI,
),
).toBe(ui);
expect(resources.result.cacheScope).toBe('private');
const preview = await request(
'tools/call',
{ name: 'preview_message', arguments: draft },
ui,
);
expect(preview.result.structuredContent).toMatchObject({
result: { can_send: true, draft },
});
}
expect(onebot.call).not.toHaveBeenCalled();
expect(onebot.callStream).not.toHaveBeenCalled();
});
});
describe('message preview tool', () => {
it('negotiates optional HTML support and retains text-only tool discovery', () => {
for (const capabilities of [
undefined,
{},
{ extensions: {} },
{ extensions: { 'io.modelcontextprotocol/ui': {} } },
]) {
expect(supportsMessageApp(capabilities)).toBe(false);
}
expect(
supportsMessageApp({
extensions: {
'io.modelcontextprotocol/ui': { mimeTypes: [MESSAGE_APP_MIME_TYPE] },
},
}),
).toBe(true);
expect(messagePreviewTool(false)._meta).toBeUndefined();
expect(messagePreviewTool(true)._meta).toEqual({
ui: { resourceUri: MESSAGE_APP_URI, visibility: ['model', 'app'] },
});
expect(messageAppResources(false)).toEqual([]);
expect(messageAppResources(true)).toEqual([
expect.objectContaining({ uri: MESSAGE_APP_URI, mimeType: MESSAGE_APP_MIME_TYPE }),
]);
});
it('returns a literal text draft and preserves negative quoted-message identifiers without executing', () => {
const result = previewMessage(
{ ...draft, text: '[CQ:at,qq=all]<script>bad()</script>', reply_to: -42 },
true,
);
expect(result.isError).toBeUndefined();
expect(result.structuredContent.result).toEqual({
draft: { ...draft, text: '[CQ:at,qq=all]<script>bad()</script>', reply_to: -42 },
can_send: true,
notice: '此为待发送草稿,尚未发送。',
invocation: {
name: 'invoke_action',
arguments: {
action: 'send_group_msg',
params: {
group_id: 12345,
message: [
{ type: 'reply', data: { id: '-42' } },
{
type: 'text',
data: { text: '[CQ:at,qq=all]<script>bad()</script>' },
},
],
},
},
},
});
expect(result.content).toEqual([
{ type: 'text', text: JSON.stringify(result.structuredContent.result, null, 2) },
]);
expect(
previewMessage({ ...draft, message_type: 'private' }, false).structuredContent.result,
).toMatchObject({
can_send: false,
invocation: { arguments: { action: 'send_private_msg', params: { user_id: 12345 } } },
});
});
it.each([
{ ...draft, message_type: 'other' },
{ ...draft, target_id: 0 },
{ ...draft, target_id: '12345' },
{ ...draft, target_id: Number.MAX_SAFE_INTEGER + 1 },
{ ...draft, text: ' \n' },
{ ...draft, reply_to: 0 },
{ ...draft, reply_to: 2147483648 },
{ ...draft, reply_to: -2147483649 },
{ ...draft, message: [] },
])('rejects an invalid draft without discarding fields: %j', (args) => {
expect(previewMessage(args, true)).toMatchObject({
isError: true,
structuredContent: { diagnostics: { kind: 'invalid_arguments' } },
});
});
it('does not hide a missing packaged panel', async () => {
await expect(readMessageAppResource('unrelated://resource')).resolves.toBeUndefined();
// Tests run from source; only an actual build supplies the sibling HTML.
await expect(readMessageAppResource(MESSAGE_APP_URI)).rejects.toMatchObject({
code: 'ENOENT',
});
});
it('builds a self-contained panel with exact CSP hashes and no external assets', async () => {
const directory = await mkdtemp(join(tmpdir(), 'snowluma-message-app-'));
temporary.push(directory);
const path = await buildMessageApp(directory);
const html = await readFile(path, 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)?.[1];
const style = html.match(/<style>([\s\S]*?)<\/style>/)?.[1];
expect(script).toBeTruthy();
expect(style).toBeTruthy();
expect(html).toContain(
`script-src 'sha256-${createHash('sha256').update(script!).digest('base64')}'`,
);
expect(html).toContain(
`style-src 'sha256-${createHash('sha256').update(style!).digest('base64')}'`,
);
expect(html).toContain("connect-src 'none'");
expect(html).not.toMatch(/<(?:script|img|iframe)\b[^>]*\bsrc\s*=/i);
expect(html).not.toContain('unsafe-inline');
expect(html).not.toContain('unsafe-eval');
expect(html).toContain('ui/initialize');
expect(html).toContain('发送这条消息');
});
});
class TestElement {
textContent = '';
disabled = true;
private readonly listeners = new Map<string, () => void | Promise<void>>();
set innerHTML(_value: string) {
throw new Error('Untrusted markup must never be rendered');
}
addEventListener(type: string, callback: () => void | Promise<void>): void {
this.listeners.set(type, callback);
}
async click(): Promise<void> {
await this.listeners.get('click')?.();
}
}
async function panel(options: { writable?: boolean; hostTools?: boolean } = {}) {
const elements = Object.fromEntries(
['target', 'message', 'reply', 'status', 'send', 'cancel'].map((id) => [
id,
new TestElement(),
]),
);
const app: PreviewApp = {
ontoolresult: undefined,
ontoolcancelled: undefined,
onerror: undefined,
onclose: undefined,
connect: vi.fn(async () => {}),
getHostCapabilities: () => (options.hostTools === false ? {} : { serverTools: {} }),
callServerTool: vi.fn(async () => ({
content: [],
structuredContent: {
result: { status: 'ok', retcode: 0, data: { message_id: -42 } },
diagnostics: { outcome: 'success' },
},
})),
};
mountMessagePreview(app, {
getElementById: (id: string) => elements[id],
} as unknown as Document);
await Promise.resolve();
app.ontoolresult?.(previewMessage({ ...draft, reply_to: -42 }, options.writable !== false));
return { elements, app };
}
describe('message preview interaction', () => {
it('uses the official app handshake, tool-result notification and host-proxied send', async () => {
const app = new App(
{ name: 'preview-test', version: '1.0.0' },
{},
{ autoResize: false, strict: true },
);
const bridge = new AppBridge(
null,
{ name: 'isolated-host', version: '1.0.0' },
{ serverTools: {} },
);
bridge.oncalltool = vi.fn(async () => ({
content: [],
structuredContent: { result: { status: 'ok', retcode: 0, data: { message_id: -99 } } },
}));
const [appTransport, hostTransport] = InMemoryTransport.createLinkedPair();
close.push(
() => app.close(),
() => bridge.close(),
);
await bridge.connect(hostTransport);
const originalConnect = app.connect.bind(app);
let connected: Promise<void> | undefined;
vi.spyOn(app, 'connect').mockImplementation(() => {
connected = originalConnect(appTransport);
return connected;
});
const elements = Object.fromEntries(
['target', 'message', 'reply', 'status', 'send', 'cancel'].map((id) => [
id,
new TestElement(),
]),
);
mountMessagePreview(app, {
getElementById: (id: string) => elements[id],
} as unknown as Document);
await connected;
await bridge.sendToolInput({ arguments: draft });
await bridge.sendToolResult(previewMessage(draft, true));
await new Promise((resolve) => setImmediate(resolve));
expect(bridge.oncalltool).not.toHaveBeenCalled();
expect(elements.target.textContent).toBe('群聊 · 12345');
await elements.send.click();
expect(bridge.oncalltool).toHaveBeenCalledTimes(1);
expect(elements.status.textContent).toBe('服务端已接受消息。消息编号:-99');
});
it('renders untrusted text without markup and never sends on load or repeated results', async () => {
const { app, elements } = await panel();
app.ontoolresult?.(
previewMessage({ ...draft, text: '<img src=x onerror=alert(1)>' }, true),
);
expect(elements.message.textContent).toBe('<img src=x onerror=alert(1)>');
expect(app.callServerTool).not.toHaveBeenCalled();
expect(elements.send.disabled).toBe(false);
});
it('sends once only on click through the existing invocation and displays a signed result', async () => {
const { app, elements } = await panel();
await Promise.all([elements.send.click(), elements.send.click()]);
app.ontoolresult?.(previewMessage(draft, true));
await elements.send.click();
expect(app.callServerTool).toHaveBeenCalledExactlyOnceWith({
name: 'invoke_action',
arguments: {
action: 'send_group_msg',
params: {
group_id: 12345,
message: [
{ type: 'reply', data: { id: '-42' } },
{ type: 'text', data: { text: '你好' } },
],
},
},
});
expect(elements.status.textContent).toBe('服务端已接受消息。消息编号:-42');
expect(elements.send.disabled).toBe(true);
});
it.each([{ writable: false }, { hostTools: false }])(
'keeps unsupported or read-only panels non-sending: %j',
async (options) => {
const { app, elements } = await panel(options);
await elements.send.click();
expect(elements.send.disabled).toBe(true);
expect(app.callServerTool).not.toHaveBeenCalled();
},
);
it('cancels drafts locally and does not re-arm after another notification', async () => {
const { app, elements } = await panel();
await elements.cancel.click();
app.ontoolresult?.(previewMessage(draft, true));
await elements.send.click();
expect(app.callServerTool).not.toHaveBeenCalled();
expect(elements.status.textContent).toContain('未发送消息');
});
it('does not retry or claim success after a transport failure', async () => {
const { app, elements } = await panel();
vi.mocked(app.callServerTool).mockRejectedValueOnce(new Error('connection lost'));
await elements.send.click();
await elements.send.click();
expect(app.callServerTool).toHaveBeenCalledTimes(1);
expect(elements.status.textContent).toContain('尚未确认');
expect(elements.send.disabled).toBe(true);
});
it('recognizes business failures even when MCP isError is false', async () => {
const { app, elements } = await panel();
vi.mocked(app.callServerTool).mockResolvedValueOnce({
content: [],
structuredContent: {
result: { status: 'failed', retcode: 1400 },
diagnostics: { outcome: 'failed', message: '<b>拒绝</b>' },
},
});
await elements.send.click();
expect(elements.status.textContent).toContain('发送失败:<b>拒绝</b>');
expect(elements.send.disabled).toBe(true);
});
it('stops sending when the host disconnects', async () => {
const { app, elements } = await panel();
app.onclose?.();
await elements.send.click();
expect(app.callServerTool).not.toHaveBeenCalled();
expect(elements.status.textContent).toContain('连接已断开');
});
});
@@ -0,0 +1,637 @@
import { spawnSync } from 'node:child_process';
import { copyFileSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync } from 'node:fs';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { Client as LegacyClient } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport as LegacyStdioTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { createMcpServer } from '../src/server-factory';
import type { ActionClient } from '../src/client';
import type { Mode } from '../src/tools';
import { buildMessageApp } from '../scripts/build-message-app.mjs';
const REVISION = '2026-07-28';
const URI = 'snowluma://onebot/actions';
const root = fileURLToPath(new URL('..', import.meta.url));
const cleanup: Array<() => Promise<void>> = [];
let build: string;
beforeAll(async () => {
build = mkdtempSync(path.join(tmpdir(), 'snowluma-mcp-protocol-'));
copyFileSync(path.join(root, 'package.json'), path.join(build, 'package.json'));
symlinkSync(path.join(root, 'node_modules'), path.join(build, 'node_modules'), 'junction');
const compiler = createRequire(import.meta.url).resolve('typescript/bin/tsc');
const result = spawnSync(
process.execPath,
[
compiler,
'--project',
path.join(root, 'tsconfig.json'),
'--outDir',
path.join(build, 'dist'),
],
{ encoding: 'utf8', timeout: 30_000 },
);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`MCP compilation failed:\n${result.stdout}\n${result.stderr}`);
}
await buildMessageApp(path.join(build, 'dist'));
}, 30_000);
afterEach(async () => {
for (const close of cleanup.splice(0).reverse()) {
await close();
}
vi.restoreAllMocks();
});
afterAll(() => {
if (build) {
rmSync(build, { recursive: true, force: true });
}
});
function fakeOneBot(): ActionClient {
return {
call: vi.fn(async () => ({ status: 'ok', retcode: 0, data: { message_id: -42 } })),
callStream: vi.fn(async (_action, _params, options) => {
await options?.onProgress?.({ progress: 0, message: 'start' });
await options?.onProgress?.({ progress: 5, total: 5, message: 'done' });
return { frame_count: 1, frames: [], terminal: { status: 'ok', retcode: 0 } };
}),
};
}
function handler(mode: Mode = 'docs', onebot?: ActionClient) {
const instance = createMcpHandler(() => createMcpServer({ mode, client: onebot }));
cleanup.push(() => instance.close());
return instance;
}
async function request(
instance: ReturnType<typeof handler>,
method: string,
params: Record<string, unknown> = {},
version = REVISION,
) {
const response = await instance.fetch(
new Request('http://mcp.invalid/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'Mcp-Method': method,
'Mcp-Protocol-Version': version,
...(typeof params.name === 'string'
? { 'Mcp-Name': params.name }
: typeof params.uri === 'string'
? { 'Mcp-Name': params.uri }
: {}),
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method,
params: {
...params,
_meta: {
'io.modelcontextprotocol/protocolVersion': version,
'io.modelcontextprotocol/clientCapabilities': {},
'io.modelcontextprotocol/clientInfo': {
name: 'protocol-test',
version: '1.0.0',
},
},
},
}),
}),
);
return { status: response.status, body: await response.json() };
}
async function connect(mode: Mode = 'write', onebot = fakeOneBot()) {
const instance = handler(mode, onebot);
const client = new Client(
{ name: 'protocol-test', version: '1.0.0' },
{
versionNegotiation: { mode: { pin: REVISION } },
},
);
const transport = new StreamableHTTPClientTransport(new URL('http://mcp.invalid/mcp'), {
fetch: (input, init) => instance.fetch(new Request(input, init)),
});
cleanup.push(() => client.close());
await client.connect(transport);
return client;
}
describe('modern protocol and cache policy', () => {
it('discovers and calls tools without initialize, with versioned results', async () => {
const instance = handler();
const discover = await request(instance, 'server/discover');
expect(discover.status).toBe(200);
expect(discover.body).toMatchObject({
result: {
resultType: 'complete',
capabilities: { tools: {}, resources: {} },
},
});
expect(JSON.stringify(discover.body.result)).toContain(REVISION);
const result = await request(instance, 'tools/call', {
name: 'get_action',
arguments: { name: 'get_status' },
});
expect(result.body.result).toMatchObject({
resultType: 'complete',
structuredContent: { diagnostics: { outcome: 'success' } },
});
expect(JSON.parse(result.body.result.content[0].text).name).toBe('get_status');
expect(result.body.result._meta['io.modelcontextprotocol/serverInfo'].name).toBe(
'snowluma-mcp',
);
});
it('rejects unsupported versions before dispatching any action', async () => {
const onebot = fakeOneBot();
const result = await request(
handler('write', onebot),
'tools/call',
{
name: 'invoke_action',
arguments: { action: 'send_group_msg', params: { group_id: 1, message: 'test' } },
},
'2999-01-01',
);
expect(result.body.error.code).toBe(-32022);
expect(onebot.call).not.toHaveBeenCalled();
});
it('caches public catalog resources but keeps mode-dependent tools private', async () => {
const instance = handler();
for (const [method, params, scope] of [
['tools/list', {}, 'private'],
['resources/list', {}, 'private'],
['resources/read', { uri: URI }, 'public'],
['resources/templates/list', {}, 'public'],
] as const) {
const { body } = await request(instance, method, params);
expect(body.result).toMatchObject({
resultType: 'complete',
ttlMs: 300_000,
cacheScope: scope,
});
}
const { body } = await request(instance, 'tools/call', { name: 'get_runtime_info' });
expect(body.result).not.toHaveProperty('ttlMs');
expect(body.result).not.toHaveProperty('cacheScope');
expect(body.result.structuredContent.result.connection_checked).toBe(false);
});
it.each(['docs', 'read', 'write'] as const)(
'keeps execution gates in %s mode',
async (mode) => {
const onebot = fakeOneBot();
const client = await connect(mode, onebot);
expect(client.getProtocolEra()).toBe('modern');
const listed = (await client.listTools()).tools.map((tool) => tool.name);
expect(listed.includes('invoke_action')).toBe(mode === 'write');
const result = await client.callTool({
name: 'invoke_action',
arguments: { action: 'send_group_msg', params: { group_id: 1, message: 'test' } },
});
expect(result.isError === true).toBe(mode !== 'write');
expect(onebot.call).toHaveBeenCalledTimes(mode === 'write' ? 1 : 0);
if (mode === 'write') {
expect(result.structuredContent).toMatchObject({
result: { data: { message_id: -42 } },
});
}
},
);
it('preserves business-error envelopes and unknown-tool errors', async () => {
const onebot = fakeOneBot();
const failure = { status: 'failed', retcode: 1400, data: null, wording: 'rejected' };
onebot.call = vi.fn(async () => failure);
const client = await connect('write', onebot);
const result = await client.callTool({
name: 'query_action',
arguments: { action: 'get_status' },
});
expect(result.isError).toBeUndefined();
expect(result.structuredContent).toMatchObject({
result: failure,
diagnostics: { outcome: 'failed', kind: 'business' },
});
expect(
JSON.parse(result.content[0]!.type === 'text' ? result.content[0]!.text : ''),
).toEqual(failure);
const missing = await client.callTool({ name: 'not_a_tool' });
expect(missing).toMatchObject({
isError: true,
structuredContent: { diagnostics: { kind: 'not_found' } },
});
});
it('correlates modern calls without exposing caller content in diagnostics', async () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const onebot = fakeOneBot();
const client = await connect('write', onebot);
const parent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
const result = await client.callTool({
name: 'invoke_action',
arguments: {
action: 'send_group_msg',
params: { group_id: 1, message: 'private-body' },
},
_meta: {
traceparent: parent,
tracestate: 'vendor=private-state',
baggage: 'private-baggage',
},
});
const span = result._meta?.traceparent;
expect(span).toMatch(/^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/);
expect(span).not.toBe(parent);
expect(onebot.call).toHaveBeenCalledWith(
'send_group_msg',
{ group_id: 1, message: 'private-body' },
expect.objectContaining({
trace: { traceparent: span, tracestate: 'vendor=private-state' },
}),
);
const entries = log.mock.calls.map(([, entry]) => JSON.parse(String(entry)));
expect(entries).toEqual([
{ event: 'call_started', tool: 'invoke_action', traceparent: span },
{
event: 'call_finished',
tool: 'invoke_action',
traceparent: span,
outcome: 'success',
duration_ms: expect.any(Number),
},
]);
expect(JSON.stringify(log.mock.calls)).not.toMatch(
/private-body|private-state|private-baggage/,
);
});
it('reports stream progress', async () => {
const onebot = fakeOneBot();
const client = await connect('write', onebot);
const progress: number[] = [];
await client.callTool(
{ name: 'invoke_action', arguments: { action: 'test_download_stream' } },
{
onprogress: (update) => {
progress.push(update.progress);
},
},
);
expect(progress).toEqual([0, 5]);
});
it.each(['cancel', 'disconnect'] as const)(
'propagates %s without retrying and records the cancellation',
async (termination) => {
const onebot = fakeOneBot();
const client = await connect('write', onebot);
const started = Promise.withResolvers<void>();
const aborted = Promise.withResolvers<void>();
const diagnosed = Promise.withResolvers<void>();
vi.spyOn(console, 'error').mockImplementation((_prefix, serialized) => {
const entry = JSON.parse(String(serialized));
if (entry.event === 'call_cancelled') {
diagnosed.resolve();
}
});
onebot.call = vi.fn(
async (_action, _params, options) =>
new Promise((_resolve, reject) => {
options?.signal?.addEventListener(
'abort',
() => {
aborted.resolve();
reject(options.signal?.reason);
},
{ once: true },
);
started.resolve();
}),
);
const controller = new AbortController();
const outcome = client
.callTool(
{ name: 'query_action', arguments: { action: 'get_status' } },
{ signal: controller.signal },
)
.catch((error) => error);
await started.promise;
if (termination === 'cancel') {
controller.abort();
} else {
await client.close();
}
expect(await outcome).toBeInstanceOf(Error);
await aborted.promise;
await diagnosed.promise;
expect(onebot.call).toHaveBeenCalledOnce();
},
);
});
describe('published stdio entry', () => {
// These subprocesses execute freshly compiled output, never a workspace dist
// file or a live account. Explicit docs mode ignores any inherited endpoint.
const parameters = () => ({
command: process.execPath,
args: [path.join(build, 'dist/server.js')],
env: { SNOWLUMA_MCP_MODE: 'docs' },
stderr: 'pipe' as const,
});
it('rejects invalid event configuration before reporting ready or acquiring a task lease', () => {
const directory = mkdtempSync(path.join(tmpdir(), 'snowluma-mcp-invalid-config-'));
cleanup.push(async () => rmSync(directory, { recursive: true, force: true }));
const result = spawnSync(process.execPath, parameters().args, {
encoding: 'utf8',
timeout: 5000,
env: {
...process.env,
SNOWLUMA_MCP_MODE: 'write',
SNOWLUMA_MCP_ENDPOINT: 'http://127.0.0.1:1/',
SNOWLUMA_MCP_EVENTS_URL: 'https://invalid.example/',
SNOWLUMA_MCP_TASK_DIR: path.join(directory, 'tasks'),
},
});
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stderr).toContain('SNOWLUMA_MCP_EVENTS_URL must use ws/wss');
expect(result.stderr).not.toContain('ready');
expect(readdirSync(directory)).toEqual([]);
});
it('still serves the v1 client using its existing initialize handshake', async () => {
const client = new LegacyClient({ name: 'legacy-test', version: '1.0.0' });
const transport = new LegacyStdioTransport(parameters());
const send = vi.spyOn(transport, 'send');
cleanup.push(() => client.close());
await client.connect(transport);
expect(
send.mock.calls.some(
([message]) => 'method' in message && message.method === 'initialize',
),
).toBe(true);
const tools = await client.listTools();
expect(tools.tools.map((tool) => tool.name)).toContain('get_action');
expect(tools).not.toHaveProperty('ttlMs');
expect(tools).not.toHaveProperty('cacheScope');
expect(tools).not.toHaveProperty('resultType');
const result = await client.callTool({
name: 'get_action',
arguments: { name: 'get_status' },
});
expect(result.isError).toBeUndefined();
expect(result).not.toHaveProperty('resultType');
expect(result).not.toHaveProperty('ttlMs');
});
it('serves a modern client from the same command without initialize', async () => {
const client = new Client(
{ name: 'modern-test', version: '1.0.0' },
{
versionNegotiation: { mode: { pin: REVISION } },
capabilities: {
extensions: {
'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] },
},
},
},
);
const transport = new StdioClientTransport(parameters());
const send = vi.spyOn(transport, 'send');
cleanup.push(() => client.close());
await client.connect(transport);
expect(client.getProtocolEra()).toBe('modern');
expect(
send.mock.calls.some(
([message]) => 'method' in message && message.method === 'initialize',
),
).toBe(false);
const result = await client.callTool({
name: 'get_action',
arguments: { name: 'get_status' },
});
expect(result.isError).toBeUndefined();
expect(result.structuredContent).toMatchObject({ diagnostics: { outcome: 'success' } });
expect((await client.readResource({ uri: URI })).contents).toHaveLength(1);
const app = await client.readResource({ uri: 'ui://snowluma/message-preview.html' });
expect(app.contents[0]).toMatchObject({
mimeType: 'text/html;profile=mcp-app',
text: expect.stringContaining('消息草稿预览'),
});
});
it.each(['eof', 'SIGTERM'] as const)(
'persists tasks across CLI restart after %s without replaying transfers',
async (shutdown) => {
const directory = mkdtempSync(path.join(tmpdir(), 'snowluma-mcp-cli-tasks-'));
cleanup.push(async () => rmSync(directory, { recursive: true, force: true }));
const requests: unknown[] = [];
const pendingStarted = Promise.withResolvers<void>();
const endpoint = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.from(chunk));
}
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
requests.push(body);
res.setHeader('content-type', 'application/json');
if (body.params.pending) {
res.write(
`${JSON.stringify({ status: 'ok', retcode: 0, data: { type: 'stream', data: 'started' } })}\r\n\r\n`,
);
pendingStarted.resolve();
} else {
res.end(
`${JSON.stringify({ status: 'ok', retcode: 0, data: { type: 'response', data: 'complete' } })}\r\n\r\n`,
);
}
});
endpoint.listen(0, '127.0.0.1');
await once(endpoint, 'listening');
cleanup.push(async () => {
endpoint.closeAllConnections();
await new Promise<void>((resolve, reject) =>
endpoint.close((error) => (error ? reject(error) : resolve())),
);
});
const address = endpoint.address();
if (!address || typeof address === 'string') {
throw new Error('Missing isolated HTTP address');
}
const taskDirectory = path.join(directory, 'tasks');
const open = async () => {
const transport = new StdioClientTransport({
...parameters(),
env: {
SNOWLUMA_MCP_MODE: 'write',
SNOWLUMA_MCP_ENDPOINT: `http://127.0.0.1:${address.port}/`,
SNOWLUMA_MCP_TOKEN: 'private-test-token',
SNOWLUMA_MCP_TASK_DIR: taskDirectory,
SNOWLUMA_MCP_STREAM_DIR: path.join(directory, 'downloads'),
},
});
cleanup.push(() => transport.close());
const waiting = new Map<
number,
ReturnType<typeof Promise.withResolvers<Record<string, unknown>>>
>();
let id = 0;
const closed = Promise.withResolvers<void>();
let diagnostic = '';
transport.stderr?.on('data', (chunk) => {
diagnostic += String(chunk);
});
transport.onmessage = (message) => {
if ('id' in message && typeof message.id === 'number') {
const result = waiting.get(message.id);
waiting.delete(message.id);
if ('error' in message) {
result?.reject(new Error(JSON.stringify(message.error)));
} else if ('result' in message) {
result?.resolve(message.result);
}
}
};
transport.onclose = () => {
for (const promise of waiting.values()) {
promise.reject(new Error(`CLI closed: ${diagnostic}`));
}
waiting.clear();
closed.resolve();
};
await transport.start();
const request = async (method: string, params: Record<string, unknown> = {}) => {
const result = Promise.withResolvers<Record<string, unknown>>();
const requestId = ++id;
waiting.set(requestId, result);
await transport.send({
jsonrpc: '2.0',
id: requestId,
method,
params: {
...params,
_meta: {
'io.modelcontextprotocol/protocolVersion': REVISION,
'io.modelcontextprotocol/clientCapabilities': {
extensions: { 'io.modelcontextprotocol/tasks': {} },
},
},
},
});
return result.promise;
};
await request('server/discover');
return {
request,
close: async (signal?: 'SIGTERM') => {
if (signal) {
if (transport.pid === null) {
throw new Error('Missing CLI process');
}
process.kill(transport.pid, signal);
await closed.promise;
} else {
await transport.close();
}
},
};
};
const first = await open();
const handle = await first.request('tools/call', {
name: 'invoke_action_background',
arguments: { action: 'test_download_stream' },
});
expect(handle).toMatchObject({ resultType: 'task', status: 'working', ttlMs: null });
const completedId = handle.taskId;
if (typeof completedId !== 'string') {
throw new Error('Missing task identifier');
}
await vi.waitFor(async () => {
expect(await first.request('tasks/get', { taskId: completedId })).toMatchObject({
status: 'completed',
result: {
resultType: 'complete',
structuredContent: { diagnostics: { outcome: 'success' } },
},
});
});
const interrupted = await first.request('tools/call', {
name: 'invoke_action_background',
arguments: { action: 'test_download_stream', params: { pending: true } },
});
await pendingStarted.promise;
await first.close(shutdown === 'SIGTERM' ? shutdown : undefined);
expect(readdirSync(taskDirectory).filter((file) => file.startsWith('.lease-'))).toEqual(
[],
);
const second = await open();
expect(await second.request('tasks/get', { taskId: completedId })).toMatchObject({
status: 'completed',
});
expect(await second.request('tasks/get', { taskId: interrupted.taskId })).toMatchObject(
{
status: 'failed',
statusMessage: expect.stringContaining('unknown'),
},
);
expect(requests).toHaveLength(2);
const persisted = readdirSync(taskDirectory)
.map((file) => readFileSync(path.join(taskDirectory, file), 'utf8'))
.join('\n');
expect(persisted).not.toContain('private-test-token');
await second.close();
expect(readdirSync(taskDirectory).filter((file) => file.startsWith('.lease-'))).toEqual(
[],
);
},
);
});
+228
View File
@@ -0,0 +1,228 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import { createMcpServer } from '../src/server-factory';
import { createProgressReporter } from '../src/progress';
import type { ActionClient, TransferProgress } from '../src/client';
const close: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const cleanup of close.splice(0).reverse()) {
await cleanup();
}
});
async function connect(onebot?: ActionClient) {
const server = createMcpServer({ mode: onebot ? 'write' : 'docs', client: onebot });
const client = new Client({ name: 'test-agent', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
close.push(
() => server.close(),
() => client.close(),
);
await server.connect(serverTransport);
await client.connect(clientTransport);
return { client, serverTransport };
}
function fakeOneBot(): ActionClient {
return {
call: vi.fn(async () => ({ status: 'ok', retcode: 0, data: { online: true } })),
callStream: vi.fn(async (_action, _params, options) => {
for (const progress of [0, 3, 7]) {
await options?.onProgress?.({ progress, total: 7, message: 'transferred bytes' });
}
return { frame_count: 2, frames: [], terminal: { status: 'ok', retcode: 0 } };
}),
};
}
describe('MCP protocol compatibility', () => {
it('accepts legacy trace metadata and returns its correlation without changing the tool result', async () => {
const onebot = fakeOneBot();
const { client } = await connect(onebot);
const parent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
const result = await client.callTool({
name: 'query_action',
arguments: { action: 'get_status' },
_meta: { traceparent: parent },
});
expect(result._meta?.traceparent).toMatch(
/^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/,
);
expect(onebot.call).toHaveBeenCalledWith(
'get_status',
{},
expect.objectContaining({
trace: { traceparent: result._meta?.traceparent },
}),
);
expect(result.content).toEqual([
{
type: 'text',
text: JSON.stringify({ status: 'ok', retcode: 0, data: { online: true } }, null, 2),
},
]);
});
it('publishes output schemas that a real MCP client can validate for every tool', async () => {
const { client } = await connect(fakeOneBot());
const tools = await client.listTools();
for (const tool of tools.tools) {
expect(tool.outputSchema).toBeDefined();
const result = await client.callTool({
name: tool.name,
arguments:
tool.name === 'preview_message'
? { message_type: 'group', target_id: 1, text: 'preview' }
: { name: 'get_status', query: 'history', action: 'get_status' },
});
expect(result.isError).toBeUndefined();
expect(result.structuredContent).toMatchObject({ diagnostics: { outcome: 'success' } });
}
const failure = await client.callTool({
name: 'get_action',
arguments: { name: 'missing_action' },
});
expect(failure.isError).toBe(true);
expect(failure.structuredContent).toMatchObject({ diagnostics: { kind: 'not_found' } });
const resources = await client.listResources();
expect(resources.resources.map((resource) => resource.uri)).toEqual([
'snowluma://onebot/actions',
]);
const catalog = await client.readResource({ uri: 'snowluma://onebot/actions' });
expect(catalog.contents).toHaveLength(1);
});
it('sends bounded progress only when requested and flushes the final update before the response', async () => {
const { client, serverTransport } = await connect(fakeOneBot());
const send = vi.spyOn(serverTransport, 'send');
await client.callTool({
name: 'invoke_action',
arguments: { action: 'test_download_stream' },
});
expect(
send.mock.calls.some(
([message]) => 'method' in message && message.method === 'notifications/progress',
),
).toBe(false);
const progress: number[] = [];
const result = await client.callTool(
{ name: 'invoke_action', arguments: { action: 'test_download_stream' } },
undefined,
{
onprogress: (update) => {
progress.push(update.progress);
},
},
);
expect(progress).toEqual([0, 7]);
expect(result.isError).toBeUndefined();
const count = progress.length;
await new Promise((resolve) => setImmediate(resolve));
expect(progress).toHaveLength(count);
});
it.each([0, 'upload-token'])('preserves progress token %j', async (token) => {
const { client, serverTransport } = await connect(fakeOneBot());
const send = vi.spyOn(serverTransport, 'send');
await client.request(
{
method: 'tools/call',
params: {
name: 'invoke_action',
arguments: { action: 'test_download_stream' },
_meta: { progressToken: token },
},
},
CallToolResultSchema,
);
const notifications = send.mock.calls
.map(([message]) => message)
.filter(
(message) => 'method' in message && message.method === 'notifications/progress',
);
expect(notifications).toHaveLength(2);
for (const notification of notifications) {
expect(notification).toMatchObject({ params: { progressToken: token } });
}
});
it('propagates a real MCP cancellation notification to ordinary execution', async () => {
const started = Promise.withResolvers<void>();
const aborted = Promise.withResolvers<void>();
const onebot = fakeOneBot();
onebot.call = vi.fn(
async (_action, _params, options) =>
new Promise((_resolve, reject) => {
options?.signal?.addEventListener(
'abort',
() => {
aborted.resolve();
reject(options.signal?.reason);
},
{ once: true },
);
started.resolve();
}),
);
const { client } = await connect(onebot);
const controller = new AbortController();
const outcome = client
.callTool({ name: 'query_action', arguments: { action: 'get_status' } }, undefined, {
signal: controller.signal,
})
.catch((error) => error);
await started.promise;
controller.abort(new Error('cancel wait'));
expect(await outcome).toBeInstanceOf(Error);
await aborted.promise;
expect(onebot.call).toHaveBeenCalledTimes(1);
});
});
describe('per-request progress', () => {
it('rate limits, enforces increasing values, and stops on cancellation', async () => {
let time = 0;
const controller = new AbortController();
const updates: TransferProgress[] = [];
const reporter = createProgressReporter(
async (value) => {
updates.push(value);
},
controller.signal,
() => time,
);
await reporter.report({ progress: 0, message: 'start' });
await reporter.report({ progress: 1, message: 'one' });
await reporter.report({ progress: 2, message: 'two' });
expect(updates.map((value) => value.progress)).toEqual([0]);
time = 250;
await reporter.report({ progress: 3, message: 'three' });
await reporter.report({ progress: 3, message: 'duplicate' });
await reporter.finish();
expect(updates.map((value) => value.progress)).toEqual([0, 3]);
await expect(reporter.report({ progress: 2, message: 'backwards' })).rejects.toThrow(
'Invalid transfer progress',
);
controller.abort();
time = 500;
await reporter.report({ progress: 4, message: 'cancelled' });
await reporter.finish();
expect(updates).toHaveLength(2);
});
it('surfaces notification delivery failure', async () => {
const reporter = createProgressReporter(async () => {
throw new Error('transport closed');
}, new AbortController().signal);
await expect(reporter.report({ progress: 1, message: 'one' })).rejects.toThrow(
'transport closed',
);
});
});
+155
View File
@@ -0,0 +1,155 @@
import { PassThrough } from 'node:stream';
import { Server } from '@modelcontextprotocol/server';
import { serveStdio, StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { shutdownRuntime } from '../src/shutdown';
afterEach(() => vi.restoreAllMocks());
describe('runtime shutdown', () => {
it('finishes durable cleanup before closing the protocol normally', async () => {
const durable = Promise.withResolvers<void>();
const closeProtocol = vi.fn(async () => undefined);
const forceClose = vi.fn(async () => undefined);
const onTimeout = vi.fn();
const closing = shutdownRuntime({
closeFeatures: () => durable.promise,
closeProtocol,
forceClose,
onTimeout,
});
await Promise.resolve();
expect(closeProtocol).not.toHaveBeenCalled();
durable.resolve();
await closing;
expect(closeProtocol).toHaveBeenCalledOnce();
expect(forceClose).not.toHaveBeenCalled();
expect(onTimeout).not.toHaveBeenCalled();
});
it('releases features and forcibly closes real stdio when a subscriber stops reading', async () => {
const input = new PassThrough();
const output = new PassThrough({ highWaterMark: 1 });
const transport = new StdioServerTransport(input, output);
const handle = serveStdio(
() =>
new Server(
{ name: 'shutdown-test', version: '1' },
{ capabilities: { resources: { subscribe: true, listChanged: true } } },
),
{ transport },
);
const order: string[] = [];
const onTimeout = vi.fn(() => order.push('timeout'));
const forceClose = vi.fn(async () => {
order.push('force');
await transport.close();
output.destroy();
});
try {
input.write(
`${JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'subscriptions/listen',
params: {
notifications: { resourcesUpdated: ['test://events'] },
_meta: {
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
'io.modelcontextprotocol/clientCapabilities': {},
},
},
})}\n`,
);
await vi.waitFor(() => expect(output.writableLength).toBeGreaterThan(0));
await shutdownRuntime({
closeFeatures: async () => {
order.push('features');
},
closeProtocol: () => {
order.push('protocol');
return handle.close();
},
forceClose,
graceMs: 10,
onTimeout,
});
expect(order).toEqual(['features', 'protocol', 'timeout', 'force']);
expect(forceClose).toHaveBeenCalledOnce();
expect(output.destroyed).toBe(true);
expect(input.listenerCount('data')).toBe(0);
} finally {
await transport.close();
input.destroy();
output.destroy();
}
});
it('still closes the protocol after a feature failure and preserves that error', async () => {
const failure = new Error('feature shutdown failed');
const closeProtocol = vi.fn(async () => undefined);
const forceClose = vi.fn(async () => undefined);
await expect(
shutdownRuntime({
closeFeatures: async () => {
throw failure;
},
closeProtocol,
forceClose,
}),
).rejects.toBe(failure);
expect(closeProtocol).toHaveBeenCalledOnce();
expect(forceClose).not.toHaveBeenCalled();
});
it('preserves all cleanup errors and forces closure after a protocol failure', async () => {
const failures = [new Error('features'), new Error('protocol'), new Error('force')];
await expect(
shutdownRuntime({
closeFeatures: async () => {
throw failures[0];
},
closeProtocol: async () => {
throw failures[1];
},
forceClose: async () => {
throw failures[2];
},
}),
).rejects.toMatchObject({ errors: failures });
});
it('does not skip forced closure when timeout reporting fails', async () => {
const reportingError = new Error('reporting');
const forceClose = vi.fn(async () => undefined);
await expect(
shutdownRuntime({
closeFeatures: async () => undefined,
closeProtocol: () => new Promise<void>(() => undefined),
forceClose,
graceMs: 1,
onTimeout: () => {
throw reportingError;
},
}),
).rejects.toBe(reportingError);
expect(forceClose).toHaveBeenCalledOnce();
});
it('reports a protocol rejection that arrives after the shutdown deadline', async () => {
const protocol = Promise.withResolvers<void>();
const onLateProtocolError = vi.fn();
await shutdownRuntime({
closeFeatures: async () => undefined,
closeProtocol: () => protocol.promise,
forceClose: async () => undefined,
graceMs: 1,
onTimeout: vi.fn(),
onLateProtocolError,
});
const failure = new Error('late protocol failure');
protocol.reject(failure);
await vi.waitFor(() => expect(onLateProtocolError).toHaveBeenCalledWith(failure));
});
});
@@ -0,0 +1,233 @@
import { once } from 'node:events';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { Client as LegacyClient } from '@modelcontextprotocol/sdk/client/index.js';
import { ResourceUpdatedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
import { WebSocketServer, type WebSocket } from 'ws';
import { createMcpServer } from '../src/server-factory';
import type { ActionClient } from '../src/client';
const cleanup: Array<() => void | Promise<void>> = [];
const SELF = 10001;
afterEach(async () => {
for (const close of cleanup.splice(0).reverse()) {
await close();
}
vi.restoreAllMocks();
});
async function onebot() {
const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 });
const sockets = new Set<WebSocket>();
wss.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
socket.send(
JSON.stringify({
self_id: SELF,
post_type: 'meta_event',
meta_event_type: 'lifecycle',
}),
);
});
await once(wss, 'listening');
cleanup.push(async () => {
for (const socket of sockets) {
socket.terminate();
}
await new Promise<void>((resolve) => wss.close(() => resolve()));
});
const address = wss.address();
if (!address || typeof address === 'string') {
throw new Error('Test listener failed');
}
return {
url: `ws://127.0.0.1:${address.port}/event`,
sockets,
send(user = 30001) {
for (const socket of sockets) {
socket.send(
JSON.stringify({
self_id: SELF,
post_type: 'message',
message_type: 'private',
user_id: user,
message_id: -42,
message: [{ type: 'text', data: { text: 'fixture' } }],
}),
);
}
},
};
}
async function connect<T extends Client | LegacyClient>(
client: T,
url: string,
mode: 'read' | 'docs' = 'read',
) {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const http: ActionClient = {
call: vi.fn(async () => ({ status: 'ok', retcode: 0 })),
callStream: vi.fn(),
};
const handle = serveStdio(
() => createMcpServer({ mode, client: http, events: { url, timeoutMs: 2000 } }),
{ transport: serverTransport },
);
cleanup.push(
() => handle.close(),
() => client.close(),
);
await client.connect(clientTransport);
return { client, serverTransport, http };
}
function resourceData(result: { contents: Array<{ text?: unknown }> }) {
const text = result.contents[0]?.text;
if (typeof text !== 'string') {
throw new Error('Expected JSON resource data');
}
return JSON.parse(text);
}
describe('event resources over actual MCP subscription routing', () => {
it('uses modern listen filters, private uncached reads, explicit resource cleanup, and session teardown', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const source = await onebot();
const { client, http } = await connect(
new Client(
{ name: 'events-test', version: '1' },
{ versionNegotiation: { mode: { pin: '2026-07-28' } } },
),
source.url,
);
const available = await client.listTools();
expect(available.tools.map((tool) => tool.name)).toContain('subscribe_events');
const first = await client.callTool({
name: 'subscribe_events',
arguments: { self_id: SELF, user_ids: [30001] },
});
const second = await client.callTool({
name: 'subscribe_events',
arguments: { self_id: SELF, user_ids: [30002] },
});
expect(first.isError).toBeUndefined();
expect(second.isError).toBeUndefined();
const firstUri = (first.structuredContent as { result: { uri: string } }).result.uri;
const secondUri = (second.structuredContent as { result: { uri: string } }).result.uri;
const updates: string[] = [];
client.setNotificationHandler('notifications/resources/updated', (notification) => {
updates.push(notification.params.uri);
});
const listening = await client.listen({ resourceSubscriptions: [firstUri] });
expect(listening.honoredFilter.resourceSubscriptions).toEqual([firstUri]);
source.send(30001);
source.send(30002);
await vi.waitFor(() => expect(updates).toContain(firstUri));
expect(updates).not.toContain(secondUri);
const firstRead = await client.readResource({ uri: firstUri });
expect(firstRead).toMatchObject({ ttlMs: 0, cacheScope: 'private' });
expect(resourceData(firstRead).events).toMatchObject([
{ event: { message_id: -42, user_id: 30001 } },
]);
source.send(30001);
await vi.waitFor(async () =>
expect(
resourceData(await client.readResource({ uri: `${firstUri}?after=1` })).next_cursor,
).toBe(2),
);
const list = await client.listResources();
expect(list).toMatchObject({ ttlMs: 0, cacheScope: 'private' });
expect(list.resources.map((resource) => resource.uri)).toContain(secondUri);
const catalog = await client.readResource({ uri: 'snowluma://onebot/actions' });
expect(catalog).toMatchObject({ ttlMs: 300_000, cacheScope: 'public' });
await listening.close();
expect(await listening.closed).toBe('local');
const before = updates.length;
source.send(30001);
await vi.waitFor(async () =>
expect(resourceData(await client.readResource({ uri: firstUri })).next_cursor).toBe(3),
);
expect(updates).toHaveLength(before);
const stopped = await client.callTool({
name: 'unsubscribe_events',
arguments: { uri: firstUri },
});
expect(stopped.isError).toBeUndefined();
await expect(client.readResource({ uri: firstUri })).rejects.toThrow('not found');
expect(source.sockets.size).toBe(1);
expect(http.call).not.toHaveBeenCalled();
await client.close();
await vi.waitFor(() => expect(source.sockets.size).toBe(0));
});
it('requires legacy per-resource subscription before sending updates and releases on unsubscribe', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const source = await onebot();
const { client } = await connect(
new LegacyClient({ name: 'legacy-events-test', version: '1' }),
source.url,
);
const updates: string[] = [];
client.setNotificationHandler(ResourceUpdatedNotificationSchema, (notification) => {
updates.push(notification.params.uri);
});
const created = await client.callTool({
name: 'subscribe_events',
arguments: { self_id: SELF },
});
expect(created.isError).toBeUndefined();
const uri = (created.structuredContent as { result: { uri: string } }).result.uri;
source.send();
await vi.waitFor(async () =>
expect(resourceData(await client.readResource({ uri })).next_cursor).toBe(1),
);
expect(updates).toEqual([]);
await expect(client.subscribeResource({ uri: `${uri}?after=0` })).rejects.toThrow(
'canonical',
);
await client.subscribeResource({ uri });
source.send();
await vi.waitFor(() => expect(updates).toContain(uri));
const read = await client.readResource({ uri: `${uri}?after=1` });
expect(resourceData(read).events).toHaveLength(1);
expect(read).not.toHaveProperty('ttlMs');
expect(read).not.toHaveProperty('resultType');
await client.unsubscribeResource({ uri });
await vi.waitFor(() => expect(source.sockets.size).toBe(0));
await expect(client.readResource({ uri })).rejects.toThrow('not found');
});
it('keeps docs mode offline even when an event endpoint was supplied', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const source = await onebot();
const { client, http } = await connect(
new Client(
{ name: 'docs-events-test', version: '1' },
{ versionNegotiation: { mode: { pin: '2026-07-28' } } },
),
source.url,
'docs',
);
expect((await client.listTools()).tools.map((tool) => tool.name)).not.toContain(
'subscribe_events',
);
const called = await client.callTool({
name: 'subscribe_events',
arguments: { self_id: SELF },
});
expect(called.isError).toBe(true);
expect(source.sockets.size).toBe(0);
expect(http.call).not.toHaveBeenCalled();
expect(client.getServerCapabilities()?.resources?.subscribe).toBeUndefined();
});
});
+462
View File
@@ -0,0 +1,462 @@
import { once } from 'node:events';
import type { IncomingMessage } from 'node:http';
import { afterEach, describe, expect, it, vi } from 'vitest';
import WebSocket, { WebSocketServer } from 'ws';
import { EventSubscriptions, normalizeEventSubscriptionOptions } from '../src/subscriptions';
import type { StructuredResult } from '../src/results';
const SELF = 10001;
const GROUP = 20001;
const USER = 30001;
const cleanup: Array<() => void | Promise<void>> = [];
afterEach(async () => {
for (const close of cleanup.splice(0).reverse()) {
await close();
}
vi.restoreAllMocks();
});
async function source(
options: { hello?: boolean; token?: string; selfId?: number; autoPong?: boolean } = {},
) {
const server = new WebSocketServer({
host: '127.0.0.1',
port: 0,
autoPong: options.autoPong ?? true,
});
const sockets = new Set<WebSocket>();
const requests: IncomingMessage[] = [];
const received: string[] = [];
server.on('connection', (socket, request) => {
sockets.add(socket);
requests.push(request);
socket.on('close', () => sockets.delete(socket));
socket.on('message', (bytes) => received.push(bytes.toString()));
if (options.hello !== false) {
socket.send(
JSON.stringify({
self_id: options.selfId ?? SELF,
post_type: 'meta_event',
meta_event_type: 'lifecycle',
sub_type: 'connect',
}),
);
}
});
await once(server, 'listening');
cleanup.push(async () => {
for (const socket of sockets) {
socket.terminate();
}
await new Promise<void>((resolve) => server.close(() => resolve()));
});
const address = server.address();
if (typeof address !== 'object' || !address) {
throw new Error('Test WebSocket listener unavailable');
}
const url = `ws://127.0.0.1:${address.port}/event`;
const manager = new EventSubscriptions({ url, accessToken: options.token, timeoutMs: 2000 });
cleanup.push(() => manager.close());
return {
manager,
requests,
received,
sockets,
url,
send(event: unknown) {
for (const socket of sockets) {
socket.send(JSON.stringify(event));
}
},
};
}
function result(value: StructuredResult): Record<string, unknown> {
expect(value.isError).toBeUndefined();
return value.structuredContent.result as Record<string, unknown>;
}
function snapshot(manager: EventSubscriptions, uri: string) {
const entry = manager.readResource(uri).contents[0]!;
if (!('text' in entry)) {
throw new Error('Expected JSON resource');
}
return JSON.parse(entry.text);
}
async function subscribe(manager: EventSubscriptions, args: Record<string, unknown> = {}) {
const value = result(await manager.callTool('subscribe_events', { self_id: SELF, ...args }));
expect(value.status).toBe('connected');
return value.uri as string;
}
function message(fields: Record<string, unknown> = {}) {
return {
self_id: SELF,
post_type: 'message',
message_type: 'group',
group_id: GROUP,
user_id: USER,
message_id: -42,
message: [{ type: 'text', data: { text: 'fixture' } }],
...fields,
};
}
describe('isolated OneBot event resources', () => {
it('validates startup configuration before constructing an event manager or connecting', async () => {
const test = await source();
expect(
normalizeEventSubscriptionOptions({ url: test.url, accessToken: 'fixture-token' }),
).toEqual({ url: test.url, accessToken: 'fixture-token', timeoutMs: 30_000 });
expect(
normalizeEventSubscriptionOptions({ url: 'wss://EXAMPLE.invalid', timeoutMs: 1234 }),
).toEqual({ url: 'wss://example.invalid/', timeoutMs: 1234 });
for (const url of [
'not-a-url',
'https://example.invalid',
'ws://user:secret@example.invalid',
'ws://example.invalid?token=secret',
'ws://example.invalid/#secret',
]) {
expect(() => normalizeEventSubscriptionOptions({ url })).toThrow(
'SNOWLUMA_MCP_EVENTS_URL',
);
}
for (const timeoutMs of [0, -1, 0.5, NaN, Infinity]) {
expect(() => normalizeEventSubscriptionOptions({ url: test.url, timeoutMs })).toThrow(
'timeout',
);
}
expect(test.requests).toEqual([]);
});
it('connects only on explicit subscription and uses an authorization header without logging credentials', async () => {
const test = await source({ token: 'secret-event-token' });
expect(test.requests).toHaveLength(0);
expect(test.manager.resources()).toEqual([]);
const uri = await subscribe(test.manager);
expect(test.requests).toHaveLength(1);
expect(test.requests[0]!.headers.authorization).toBe('Bearer secret-event-token');
expect(test.requests[0]!.url).toBe('/event');
expect(test.manager.resources()).toMatchObject([{ uri }]);
expect(snapshot(test.manager, uri)).toMatchObject({
self_id: SELF,
status: 'connected',
events: [],
next_cursor: 0,
gap: false,
});
expect(test.received).toEqual([]);
});
it('filters event type, group and user before buffering and preserves signed message IDs', async () => {
const test = await source();
const uri = await subscribe(test.manager, {
types: ['message'],
group_ids: [GROUP],
user_ids: [USER],
});
test.send(message({ post_type: 'notice' }));
test.send(message({ group_id: GROUP + 1 }));
test.send(message({ user_id: USER + 1 }));
test.send(message({ group_id: undefined, message_type: 'private' }));
test.send(message());
test.send(message({ post_type: 'message_sent', message_id: -43 }));
await vi.waitFor(() => expect(snapshot(test.manager, uri).next_cursor).toBe(2));
const read = snapshot(test.manager, `${uri}?after=1`);
expect(read.events).toMatchObject([
{ cursor: 2, event: { message_id: -43, post_type: 'message_sent' } },
]);
expect(read.gap).toBe(false);
expect(test.received).toEqual([]);
});
it('retains bounded event and byte windows with explicit cursor gaps', async () => {
const test = await source();
const uri = await subscribe(test.manager);
for (let index = 0; index < 300; index++) {
test.send(message({ message_id: -index - 1 }));
}
await vi.waitFor(() => expect(snapshot(test.manager, uri).next_cursor).toBe(300));
const read = snapshot(test.manager, uri);
expect(read.events).toHaveLength(256);
expect(read).toMatchObject({ oldest_cursor: 45, dropped_events: 44, gap: true });
expect(snapshot(test.manager, `${uri}?after=44`).gap).toBe(false);
expect(snapshot(test.manager, `${uri}?after=300`).events).toEqual([]);
expect(() => snapshot(test.manager, `${uri}?after=301`)).toThrow('cursor');
for (let index = 0; index < 8; index++) {
test.send(message({ raw_message: 'x'.repeat(200_000) }));
}
await vi.waitFor(() => expect(snapshot(test.manager, uri).next_cursor).toBe(308));
const large = snapshot(test.manager, uri);
expect(large.events).toHaveLength(5);
expect(large.dropped_events).toBe(303);
expect(large.gap).toBe(true);
});
it('rejects another account at connection validation and never exposes its events', async () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
const test = await source({ selfId: SELF + 1 });
const reply = await test.manager.callTool('subscribe_events', { self_id: SELF });
expect(reply).toMatchObject({
isError: true,
structuredContent: { diagnostics: { kind: 'transport' } },
});
expect(test.manager.resources()).toEqual([]);
expect(log.mock.calls.flat().join(' ')).toContain('account_mismatch');
expect(log.mock.calls.flat().join(' ')).not.toContain(String(SELF + 1));
await vi.waitFor(() => expect(test.sockets.size).toBe(0));
});
it('makes account changes and disconnections visible without automatic reconnection', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const test = await source();
const uri = await subscribe(test.manager);
test.send(message());
test.send(message({ self_id: SELF + 1, raw_message: 'other-account-private-data' }));
await vi.waitFor(() => expect(snapshot(test.manager, uri).status).toBe('disconnected'));
const read = snapshot(test.manager, uri);
expect(read).toMatchObject({ gap: true, problem: 'account_mismatch', next_cursor: 1 });
expect(JSON.stringify(read)).not.toContain('other-account-private-data');
expect(test.requests).toHaveLength(1);
const retry = await test.manager.callTool('subscribe_events', { self_id: SELF });
expect(retry.isError).toBe(true);
expect(test.requests).toHaveLength(1);
});
it('shares one source for matching accounts and releases it after the last subscription', async () => {
const test = await source();
const first = await subscribe(test.manager);
const second = await subscribe(test.manager, { types: ['notice', 'request'] });
expect(test.requests).toHaveLength(1);
const mismatch = await test.manager.callTool('subscribe_events', { self_id: SELF + 1 });
expect(mismatch).toMatchObject({
isError: true,
structuredContent: { diagnostics: { kind: 'invalid_arguments' } },
});
result(await test.manager.callTool('unsubscribe_events', { uri: first }));
expect(test.sockets.size).toBe(1);
test.send(message({ post_type: 'request', request_type: 'friend' }));
await vi.waitFor(() => expect(snapshot(test.manager, second).next_cursor).toBe(1));
test.manager.remove(second);
await vi.waitFor(() => expect(test.sockets.size).toBe(0));
expect(test.manager.resources()).toEqual([]);
expect(() => snapshot(test.manager, first)).toThrow('not found');
await subscribe(test.manager, { self_id: SELF });
expect(test.requests).toHaveLength(2);
});
it('rejects malformed and oversized frames with a visible gap instead of swallowing them', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const test = await source();
const uri = await subscribe(test.manager);
test.send({ post_type: 'message', self_id: 'wrong-type' });
await vi.waitFor(() => expect(snapshot(test.manager, uri).problem).toBe('invalid_event'));
expect(snapshot(test.manager, uri).gap).toBe(true);
test.manager.remove(uri);
const large = await subscribe(test.manager);
test.send(message({ raw_message: 'x'.repeat(300_000) }));
await vi.waitFor(() =>
expect(snapshot(test.manager, large).problem).toBe('frame_too_large'),
);
expect(snapshot(test.manager, large).events).toEqual([]);
});
it('handles transport closure, cancellation and explicit close without leaking connections', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const test = await source();
const uri = await subscribe(test.manager);
for (const socket of test.sockets) {
socket.close();
}
await vi.waitFor(() =>
expect(snapshot(test.manager, uri).problem).toBe('connection_closed'),
);
test.manager.remove(uri);
const pending = await source({ hello: false });
const controller = new AbortController();
const subscription = pending.manager.callTool(
'subscribe_events',
{ self_id: SELF },
controller.signal,
);
await vi.waitFor(() => expect(pending.sockets.size).toBe(1));
controller.abort();
expect(await subscription).toMatchObject({
isError: true,
structuredContent: { diagnostics: { kind: 'cancelled' } },
});
await vi.waitFor(() => expect(pending.sockets.size).toBe(0));
expect(pending.manager.resources()).toEqual([]);
await subscribe(test.manager);
test.manager.close();
await vi.waitFor(() => expect(test.sockets.size).toBe(0));
});
it('keeps notification backlog bounded and coalesces updates behind a slow reader', async () => {
const test = await source();
const uri = await subscribe(test.manager);
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
const notify = vi.fn(async () => blocked);
test.manager.onUpdate(notify);
test.send(message());
await vi.waitFor(() => expect(notify).toHaveBeenCalledTimes(1));
for (let index = 0; index < 500; index++) {
test.send(message());
}
await vi.waitFor(() => expect(snapshot(test.manager, uri).next_cursor).toBe(501));
expect(notify).toHaveBeenCalledTimes(1);
expect(snapshot(test.manager, uri).events).toHaveLength(256);
release();
await vi.waitFor(() => expect(notify).toHaveBeenCalledTimes(2));
});
it('makes notification delivery failures observable without logging sensitive input', async () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
const test = await source({ token: 'private-token' });
const uri = await subscribe(test.manager);
const detach = test.manager.onUpdate(async () => {
throw new Error('sensitive-event-or-token');
});
test.send(message());
await vi.waitFor(() => expect(snapshot(test.manager, uri).notification_failed).toBe(true));
expect(log.mock.calls.flat().join(' ')).toContain('notification failed');
expect(log.mock.calls.flat().join(' ')).not.toContain('sensitive-event-or-token');
expect(log.mock.calls.flat().join(' ')).not.toContain('private-token');
detach();
});
it('validates filter and resource inputs and caps resource count', async () => {
const test = await source();
for (const args of [
{ self_id: -1 },
{ self_id: SELF, types: [] },
{ self_id: SELF, types: ['other'] },
{ self_id: SELF, group_ids: [GROUP, GROUP] },
{ self_id: SELF, user_ids: ['30001'] },
{ self_id: SELF, unexpected: true },
]) {
expect(await test.manager.callTool('subscribe_events', args)).toMatchObject({
isError: true,
structuredContent: { diagnostics: { kind: 'invalid_arguments' } },
});
}
expect(test.requests).toHaveLength(0);
for (let index = 0; index < 16; index++) {
await subscribe(test.manager);
}
expect((await test.manager.callTool('subscribe_events', { self_id: SELF })).isError).toBe(
true,
);
expect(test.requests).toHaveLength(1);
const uri = test.manager.resources()[0]!.uri;
expect(() => test.manager.remove(`${uri}?after=0`)).toThrow('canonical');
expect(() => snapshot(test.manager, `${uri}?after=-1`)).toThrow('Invalid event');
expect(() => snapshot(test.manager, `${uri}?after=9007199254740992`)).toThrow('cursor');
expect(() => new EventSubscriptions({ url: 'http://example.invalid' })).toThrow('ws/wss');
expect(
() => new EventSubscriptions({ url: 'ws://example.invalid?access_token=secret' }),
).toThrow('without credentials');
});
it('isolates resource identifiers and buffers between MCP sessions', async () => {
const first = await source();
const second = await source();
const uri = await subscribe(first.manager);
const other = await subscribe(second.manager);
first.send(message({ raw_message: 'first-session-private-event' }));
await vi.waitFor(() => expect(snapshot(first.manager, uri).next_cursor).toBe(1));
expect(snapshot(second.manager, other).events).toEqual([]);
expect(() => snapshot(second.manager, uri)).toThrow('not found');
expect(other).not.toBe(uri);
});
it('cleans up resources when constructing the WebSocket request fails synchronously', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const test = await source({ token: 'bad\nheader' });
const reply = await test.manager.callTool('subscribe_events', { self_id: SELF });
expect(reply.isError).toBe(true);
expect(test.manager.resources()).toEqual([]);
expect(test.requests).toEqual([]);
});
it('does not report a removed pending subscription as successfully connected', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const test = await source({ hello: false });
const first = test.manager.callTool('subscribe_events', { self_id: SELF });
const second = test.manager.callTool('subscribe_events', { self_id: SELF });
await vi.waitFor(() => expect(test.sockets.size).toBe(1));
const firstUri = test.manager.resources()[0]!.uri;
test.manager.remove(firstUri);
expect((await first).isError).toBe(true);
test.send({ self_id: SELF, post_type: 'meta_event', meta_event_type: 'lifecycle' });
expect((await second).isError).toBeUndefined();
expect(test.manager.resources()).toHaveLength(1);
expect(() => snapshot(test.manager, firstUri)).toThrow('not found');
});
it('reports a half-open connection when the WebSocket peer stops answering pings', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const timer = vi.spyOn(globalThis, 'setInterval');
const test = await source({ autoPong: false });
const uri = await subscribe(test.manager);
const heartbeat = timer.mock.calls.find((call) => call[1] === 30_000)?.[0];
if (typeof heartbeat !== 'function') {
throw new Error('Expected a heartbeat callback');
}
const socket = Array.from(test.sockets)[0]!;
const ping = once(socket, 'ping');
heartbeat();
await ping;
heartbeat();
expect(snapshot(test.manager, uri)).toMatchObject({
status: 'disconnected',
problem: 'connection_timeout',
gap: true,
});
await vi.waitFor(() => expect(test.sockets.size).toBe(0));
});
it('rejects excessively nested JSON before it can crash resource serialization', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const test = await source();
const uri = await subscribe(test.manager);
const raw = `{"self_id":${SELF},"post_type":"message","nested":${'['.repeat(10_000)}0${']'.repeat(10_000)}}`;
for (const socket of test.sockets) {
socket.send(raw);
}
await vi.waitFor(() => expect(snapshot(test.manager, uri).problem).toBe('invalid_event'));
expect(snapshot(test.manager, uri)).toMatchObject({ events: [], gap: true });
});
});
+695
View File
@@ -0,0 +1,695 @@
import { randomUUID } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import {
chmod,
mkdir,
mkdtemp,
readFile,
readdir,
rename,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises';
import { tmpdir, hostname } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createMcpHandler,
InMemoryTransport,
Server,
type CallToolResult,
} from '@modelcontextprotocol/server';
import { PersistentTasks, installTaskHandlers, supportsTasks, TASK_EXTENSION } from '../src/tasks';
import { makeHttpClient, type ActionClient } from '../src/client';
import { callTool } from '../src/tools';
import { createMcpServer } from '../src/server-factory';
const scopeId = 'a'.repeat(64);
const cleanup: Array<() => Promise<void>> = [];
const success: CallToolResult = { content: [{ type: 'text', text: 'completed' }] };
const input = {
method: 'elicitation/create' as const,
params: {
mode: 'form' as const,
message: 'Choose a value',
requestedSchema: {
type: 'object' as const,
properties: { value: { type: 'string' as const } },
},
},
};
afterEach(async () => {
for (const close of cleanup.splice(0).reverse()) {
await close();
}
vi.restoreAllMocks();
});
async function directory() {
const dir = await mkdtemp(path.join(tmpdir(), 'snowluma-tasks-'));
cleanup.push(() => rm(dir, { recursive: true, force: true }));
return dir;
}
async function openTasks(
dir?: string,
overrides: Partial<Parameters<typeof PersistentTasks.open>[1]> = {},
) {
const tasks = await PersistentTasks.open(dir ?? (await directory()), {
scopeId,
onError: vi.fn(),
...overrides,
});
cleanup.push(() => tasks.close());
return tasks;
}
async function finished(tasks: PersistentTasks, id: string) {
await vi.waitFor(() =>
expect(['completed', 'failed', 'cancelled']).toContain(tasks.get(id).status),
);
return tasks.get(id);
}
describe('durable task execution', () => {
it('does not begin a side effect after shutdown was already queued', async () => {
const tasks = await openTasks();
const execute = vi.fn(async () => success);
const started = tasks.start(execute);
const closing = tasks.close();
const [handle] = await Promise.all([started, closing]);
expect(execute).not.toHaveBeenCalled();
expect(tasks.get(handle.taskId)).toMatchObject({
status: 'failed',
statusMessage: expect.stringContaining('unknown'),
});
});
it('persists before executing, keeps terminal results across restart, and stores no callback arguments', async () => {
const dir = await directory();
const tasks = await openTasks(dir);
const handle = await tasks.start(async ({ taskId }) => {
const persisted = JSON.parse(
await readFile(path.join(dir, `${taskId}.task.json`), 'utf8'),
);
expect(persisted.task.status).toBe('working');
return success;
});
expect(handle).toMatchObject({ resultType: 'task', status: 'working', ttlMs: null });
expect(await finished(tasks, handle.taskId)).toMatchObject({
status: 'completed',
result: { ...success, resultType: 'complete' },
});
await tasks.close();
const reopened = await openTasks(dir);
expect(reopened.get(handle.taskId)).toMatchObject({ status: 'completed', result: success });
const record = JSON.parse(
await readFile(path.join(dir, `${handle.taskId}.task.json`), 'utf8'),
);
expect(Object.keys(record).sort()).toEqual(['task', 'version']);
if (process.platform !== 'win32') {
expect((await stat(dir)).mode & 0o777).toBe(0o700);
expect((await stat(path.join(dir, `${handle.taskId}.task.json`))).mode & 0o777).toBe(
0o600,
);
}
});
it('reports tool business failures as completed results rather than protocol failure', async () => {
const tasks = await openTasks();
const handle = await tasks.start(async () => ({ content: [], isError: true }));
expect(await finished(tasks, handle.taskId)).toMatchObject({
status: 'completed',
result: { isError: true },
});
});
it('does not persist raw exceptions or credentials', async () => {
const dir = await directory();
const tasks = await openTasks(dir);
const handle = await tasks.start(async () => {
throw new Error('Bearer private-secret');
});
expect(await finished(tasks, handle.taskId)).toMatchObject({
status: 'failed',
error: { code: -32603 },
});
expect(await readFile(path.join(dir, `${handle.taskId}.task.json`), 'utf8')).not.toContain(
'private-secret',
);
});
it('marks crash-interrupted records unknown without replaying work', async () => {
const dir = await directory();
const first = await openTasks(dir);
await first.close();
const id = randomUUID();
const createdAt = new Date().toISOString();
await writeFile(
path.join(dir, `${id}.task.json`),
JSON.stringify({
version: 1,
task: {
taskId: id,
status: 'working',
createdAt,
lastUpdatedAt: createdAt,
ttlMs: null,
pollIntervalMs: 1000,
},
}),
{ mode: 0o600 },
);
const reopened = await openTasks(dir);
expect(reopened.get(id)).toMatchObject({
status: 'failed',
error: { code: -32603, message: expect.stringContaining('not replayed') },
});
});
it('propagates cancellation to the execution and preserves a completed result in a race', async () => {
const tasks = await openTasks();
let observed!: AbortSignal;
const handle = await tasks.start(async ({ signal }) => {
observed = signal;
await new Promise<void>((resolve) =>
signal.addEventListener('abort', () => resolve(), { once: true }),
);
return success;
});
await tasks.cancel(handle.taskId);
expect(observed.aborted).toBe(true);
expect(await finished(tasks, handle.taskId)).toMatchObject({ status: 'completed' });
await tasks.cancel(handle.taskId);
expect(tasks.get(handle.taskId).status).toBe('completed');
});
it('settles abort-rejecting operations as cancelled and never re-executes', async () => {
const tasks = await openTasks();
const execute = vi.fn(async ({ signal }: { signal: AbortSignal }) => {
await new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), { once: true }),
);
return success;
});
const handle = await tasks.start(execute);
await tasks.cancel(handle.taskId);
expect(await finished(tasks, handle.taskId)).toMatchObject({
status: 'cancelled',
statusMessage: expect.stringContaining('does not undo'),
});
expect(execute).toHaveBeenCalledTimes(1);
});
it('closes all running operations and leaves unknown outcomes readable after restart', async () => {
const dir = await directory();
const tasks = await openTasks(dir);
let signal!: AbortSignal;
const handle = await tasks.start(async (ctx) => {
signal = ctx.signal;
return new Promise<CallToolResult>(() => undefined);
});
await tasks.close();
expect(signal.aborted).toBe(true);
const reopened = await openTasks(dir);
expect(reopened.get(handle.taskId)).toMatchObject({
status: 'failed',
statusMessage: expect.stringContaining('unknown'),
});
await expect(tasks.start(async () => success)).rejects.toThrow('closed');
});
it('marks every aborted runner terminal even when callbacks settle during close', async () => {
const tasks = await openTasks();
const handles = await Promise.all(
[1, 2, 3].map(() =>
tasks.start(async ({ signal }) => {
await new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
);
return success;
}),
),
);
await tasks.close();
for (const handle of handles) {
expect(tasks.get(handle.taskId).status).toBe('failed');
}
});
it('enforces active concurrency without deleting completed history', async () => {
const tasks = await openTasks(undefined, { maxConcurrent: 1 });
let resolve!: (value: CallToolResult) => void;
const first = await tasks.start(
() =>
new Promise<CallToolResult>((yes) => {
resolve = yes;
}),
);
await expect(tasks.start(async () => success)).rejects.toThrow('concurrency');
resolve(success);
await finished(tasks, first.taskId);
const second = await tasks.start(async () => success);
await finished(tasks, second.taskId);
const page = tasks.list({ limit: 1 });
expect(page.tasks).toHaveLength(1);
expect(page.tasks[0]).not.toHaveProperty('result');
expect(tasks.list({ cursor: page.nextCursor, limit: 1 }).tasks[0].taskId).toBe(
second.taskId,
);
});
});
describe('task input lifecycle', () => {
it('accepts partial input, ignores repeated keys, and resumes the same callback once', async () => {
const tasks = await openTasks();
const execute = vi.fn(async ({ requestInput }) => {
const responses = await requestInput({ first: input, second: input });
expect(responses).toMatchObject({
first: { action: 'accept', content: { value: 'one' } },
second: { action: 'decline' },
});
return success;
});
const handle = await tasks.start(execute);
await vi.waitFor(() => expect(tasks.get(handle.taskId).status).toBe('input_required'));
await tasks.update(handle.taskId, {
unknown: {},
first: { action: 'accept', content: { value: 'one' } },
});
expect(tasks.get(handle.taskId)).toMatchObject({
status: 'input_required',
inputRequests: { second: input },
});
expect(tasks.get(handle.taskId)).not.toHaveProperty('inputRequests.first');
await tasks.update(handle.taskId, {
first: { action: 'cancel' },
second: { action: 'decline' },
});
expect(await finished(tasks, handle.taskId)).toMatchObject({ status: 'completed' });
expect(execute).toHaveBeenCalledTimes(1);
});
it('rejects malformed matching input without losing the pending request', async () => {
const tasks = await openTasks();
const handle = await tasks.start(async ({ requestInput }) => {
await requestInput({ value: input });
return success;
});
await vi.waitFor(() => expect(tasks.get(handle.taskId).status).toBe('input_required'));
await expect(tasks.update(handle.taskId, { value: { action: 'wrong' } })).rejects.toThrow(
'Invalid task input response',
);
expect(tasks.get(handle.taskId).status).toBe('input_required');
await tasks.cancel(handle.taskId);
expect((await finished(tasks, handle.taskId)).status).toBe('cancelled');
});
it('never reuses input keys across rounds', async () => {
const tasks = await openTasks();
const handle = await tasks.start(async ({ requestInput }) => {
await requestInput({ value: input });
await requestInput({ value: input });
return success;
});
await vi.waitFor(() => expect(tasks.get(handle.taskId).status).toBe('input_required'));
await tasks.update(handle.taskId, { value: { action: 'decline' } });
expect((await finished(tasks, handle.taskId)).status).toBe('failed');
});
});
describe('task storage boundary', () => {
it('recovers a dead-process lease with an explicit diagnostic', async () => {
const dir = await directory();
const previous = spawnSync(process.execPath, ['-e', 'process.exit(0)']);
expect(previous.status).toBe(0);
const lease = path.join(dir, `.lease-${randomUUID()}.json`);
await writeFile(lease, JSON.stringify({ pid: previous.pid, host: hostname() }), {
mode: 0o600,
});
const onError = vi.fn();
await openTasks(dir, { onError });
expect(onError).toHaveBeenCalledWith(expect.any(Error), { event: 'recovered_stale_lock' });
await expect(stat(lease)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('makes persistence failures visible and still aborts all work during shutdown', async () => {
const dir = await directory();
const tasks = await openTasks(dir);
let resolve!: (value: CallToolResult) => void;
const first = await tasks.start(
() =>
new Promise<CallToolResult>((yes) => {
resolve = yes;
}),
);
let pending!: AbortSignal;
const second = await tasks.start(async ({ signal }) => {
pending = signal;
return new Promise<CallToolResult>(() => undefined);
});
const file = path.join(dir, `${first.taskId}.task.json`);
await rename(file, `${file}.saved`);
await mkdir(file);
resolve(success);
await vi.waitFor(() => expect(() => tasks.get(first.taskId)).toThrow('persistence failed'));
expect(() => tasks.get(second.taskId)).toThrow('persistence failed');
await tasks.close();
expect(pending.aborted).toBe(true);
expect((await readdir(dir)).some((entry) => entry.startsWith('.lease-'))).toBe(false);
});
it('refuses concurrent owners and scope changes', async () => {
const dir = await directory();
const owner = await openTasks(dir);
await expect(PersistentTasks.open(dir, { scopeId })).rejects.toThrow('locked');
await owner.close();
await expect(PersistentTasks.open(dir, { scopeId: 'b'.repeat(64) })).rejects.toThrow(
'different account',
);
});
it('fails clearly on corrupt records and symlinks', async () => {
const dir = await directory();
const tasks = await openTasks(dir);
await tasks.close();
const file = path.join(dir, `${randomUUID()}.task.json`);
await writeFile(file, '{broken', { mode: 0o600 });
await expect(PersistentTasks.open(dir, { scopeId })).rejects.toThrow();
await rm(file);
await symlink(path.join(dir, 'scope.json'), file);
await expect(PersistentTasks.open(dir, { scopeId })).rejects.toThrow(
'private regular file',
);
});
it('rejects traversal and unknown identifiers without touching other files', async () => {
const tasks = await openTasks();
expect(() => tasks.get('../scope')).toThrow('Invalid task identifier');
expect(() => tasks.get(randomUUID())).toThrow('Unknown task identifier');
await expect(tasks.cancel('../scope')).rejects.toThrow('Invalid task identifier');
});
it.skipIf(process.platform === 'win32')('rejects group-readable directories', async () => {
const dir = await directory();
await chmod(dir, 0o755);
await expect(PersistentTasks.open(dir, { scopeId })).rejects.toThrow('0700');
});
it('reports notification failures without changing a completed result', async () => {
const onError = vi.fn();
const tasks = await openTasks(undefined, {
onError,
onChange: () => {
throw new Error('subscriber unavailable');
},
});
const handle = await tasks.start(async () => success);
expect((await finished(tasks, handle.taskId)).status).toBe('completed');
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ event: 'task_notification_failed' }),
);
});
it('does not let a stalled subscriber block task execution or cancellation', async () => {
const tasks = await openTasks(undefined, {
onChange: () => new Promise<void>(() => undefined),
});
const handle = await tasks.start(async ({ signal }) => {
await new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), { once: true }),
);
return success;
});
await tasks.cancel(handle.taskId);
expect((await finished(tasks, handle.taskId)).status).toBe('cancelled');
});
});
describe('task cancellation reaches HTTP and upload cleanup', () => {
it.each([false, true])(
'aborts the active HTTP operation and finishes cleanup (upload: %s)',
async (upload) => {
const dir = await directory();
const inputFile = path.join(dir, 'media.bin');
await writeFile(inputFile, 'media');
const tasks = await openTasks();
const signals: AbortSignal[] = [];
const reset = vi.fn();
const client = makeHttpClient({
endpoint: 'https://unused.invalid',
streamDir: dir,
uploadRoot: dir,
fetch: async (_url, init) => {
const body = JSON.parse(String(init?.body));
if (body.params.reset) {
reset();
expect(init?.signal?.aborted).toBe(false);
return new Response(
`${JSON.stringify({ status: 'ok', retcode: 0, data: { type: 'response' } })}\r\n\r\n`,
);
}
const signal = init?.signal;
if (!signal) {
throw new Error('Expected cancellation signal');
}
signals.push(signal);
return new Promise<Response>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
});
});
},
});
const handle = await tasks.start(({ signal }) =>
callTool(
upload ? 'invoke_action' : 'query_action',
upload
? { action: 'upload_file_stream', execution: { input_file: inputFile } }
: { action: 'get_status' },
{ mode: 'write', client, signal },
),
);
await vi.waitFor(() => expect(signals).toHaveLength(1));
await tasks.cancel(handle.taskId);
const result = await finished(tasks, handle.taskId);
expect(signals[0].aborted).toBe(true);
expect(result).toMatchObject({
status: 'completed',
result: {
isError: true,
structuredContent: { diagnostics: { kind: 'cancelled', outcome: 'unknown' } },
},
});
expect(reset).toHaveBeenCalledTimes(upload ? 1 : 0);
},
);
});
describe('2026 Tasks wire handlers', () => {
it('does not activate modern tasks on a legacy connection with copied capability metadata', async () => {
const tasks = await openTasks();
const task = await tasks.start(async () => success);
const client: ActionClient = {
call: vi.fn(async () => ({ status: 'ok', retcode: 0 })),
callStream: vi.fn(async () => ({
frame_count: 0,
frames: [],
terminal: { status: 'ok', retcode: 0 },
})),
};
const server = createMcpServer({ mode: 'write', tasks, client });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
cleanup.push(() => server.close());
await server.connect(serverTransport);
await clientTransport.start();
let id = 0;
const request = async (method: string, params: Record<string, unknown> = {}) => {
const response = new Promise<unknown>((resolve) => {
clientTransport.onmessage = resolve;
});
await clientTransport.send({
jsonrpc: '2.0',
id: ++id,
method,
params: {
...params,
_meta: {
'io.modelcontextprotocol/clientCapabilities': {
extensions: { [TASK_EXTENSION]: {} },
},
},
},
});
return response;
};
expect(await request('tools/list')).toMatchObject({
result: {
tools: expect.not.arrayContaining([
expect.objectContaining({ name: 'invoke_action_background' }),
]),
},
});
expect(
await request('tools/call', {
name: 'invoke_action_background',
arguments: { action: 'test_download_stream' },
}),
).toMatchObject({
result: {
isError: true,
structuredContent: { diagnostics: { kind: 'configuration' } },
},
});
expect(client.call).not.toHaveBeenCalled();
expect(client.callStream).not.toHaveBeenCalled();
expect(tasks.list().tasks).toHaveLength(1);
expect(await request('tasks/get', { taskId: task.taskId })).toMatchObject({
error: { code: -32021 },
});
});
async function harness() {
const tasks = await openTasks();
const handler = createMcpHandler(() => {
const server = new Server(
{ name: 'task-test', version: '1' },
{ capabilities: { tools: {} } },
);
installTaskHandlers(server, tasks);
server.setRequestHandler('tools/call', (request, ctx) => {
expect(supportsTasks(ctx.mcpReq.envelope)).toBe(true);
return tasks.start(async ({ requestInput }) => {
if (request.params.name === 'input') {
await requestInput({ answer: input });
}
return success;
});
});
return server;
});
cleanup.push(() => handler.close());
const request = async (
method: string,
params: Record<string, unknown> = {},
capable = true,
) => {
const response = await handler.fetch(
new Request('http://mcp.invalid/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'Mcp-Method': method,
'Mcp-Protocol-Version': '2026-07-28',
...(typeof params.name === 'string'
? { 'Mcp-Name': params.name }
: typeof params.taskId === 'string'
? { 'Mcp-Name': params.taskId }
: {}),
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method,
params: {
...params,
_meta: {
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
'io.modelcontextprotocol/clientCapabilities': capable
? { extensions: { [TASK_EXTENSION]: {} } }
: {},
},
},
}),
}),
);
return response.json();
};
return { tasks, request };
}
it('creates a real task response and fetches durable terminal results', async () => {
const { tasks, request } = await harness();
const created = await request('tools/call', { name: 'work' });
expect(created.result).toMatchObject({ resultType: 'task', status: 'working' });
await finished(tasks, created.result.taskId);
expect(
(await request('tasks/get', { taskId: created.result.taskId })).result,
).toMatchObject({
resultType: 'complete',
status: 'completed',
result: { ...success, resultType: 'complete' },
});
expect(
(await request('tasks/cancel', { taskId: created.result.taskId })).result,
).toMatchObject({ resultType: 'complete' });
});
it('restores SDK-lifted input responses and acknowledges updates', async () => {
const { tasks, request } = await harness();
const created = await request('tools/call', { name: 'input' });
const taskId = created.result.taskId;
await vi.waitFor(() => expect(tasks.get(taskId).status).toBe('input_required'));
expect(
(
await request('tasks/update', {
taskId,
inputResponses: { irrelevant: { invalid: true } },
})
).result,
).toMatchObject({ resultType: 'complete' });
expect(
(
await request('tasks/update', {
taskId,
inputResponses: { answer: { invalid: true } },
})
).error.code,
).toBe(-32602);
expect(
(
await request('tasks/update', {
taskId,
inputResponses: { answer: { action: 'accept', content: { value: 'yes' } } },
})
).result,
).toMatchObject({ resultType: 'complete' });
expect((await finished(tasks, taskId)).status).toBe('completed');
});
it('checks the capability on every lifecycle call, and does not expose obsolete methods', async () => {
const { request } = await harness();
const created = await request('tools/call', { name: 'work' });
const taskId = created.result.taskId;
for (const method of ['tasks/get', 'tasks/update', 'tasks/cancel']) {
expect((await request(method, { taskId }, false)).error).toMatchObject({
code: -32021,
data: { requiredCapabilities: { extensions: { [TASK_EXTENSION]: {} } } },
});
}
expect((await request('tasks/get', { taskId: randomUUID() })).error.code).toBe(-32602);
expect((await request('tasks/list')).error).toBeDefined();
expect((await request('tasks/result', { taskId })).error).toBeDefined();
});
});
+339
View File
@@ -0,0 +1,339 @@
import { describe, it, expect } from 'vitest';
import { computeTools, callTool, parseMode } from '../src/tools';
import type {
ActionClient,
OneBotEnvelope,
StreamCallOptions,
StreamCallResult,
} from '../src/client';
type Res = Awaited<ReturnType<typeof callTool>>;
function text(res: Res): string {
const block = res.content?.[0];
return block && block.type === 'text' ? block.text : '';
}
function parsed(res: Res): any {
return JSON.parse(text(res));
}
/** A fake ActionClient — no transport. Records the last call and returns a
* canned envelope (overridable per test). */
function fakeClient(
impl?: (action: string, params: Record<string, unknown>) => OneBotEnvelope,
streamImpl?: (
action: string,
params: Record<string, unknown>,
options?: StreamCallOptions,
) => StreamCallResult,
): ActionClient {
return {
call: async (action, params) =>
impl?.(action, params) ?? { status: 'ok', retcode: 0, data: { action, params } },
callStream: async (action, params, options) =>
streamImpl?.(action, params, options) ?? {
frame_count: 1,
frames: [],
terminal: { status: 'ok', retcode: 0, data: { type: 'response', action, params } },
},
};
}
describe('computeTools — mode gating', () => {
it('fails fast on an invalid SNOWLUMA_MCP_MODE', () => {
expect(parseMode(undefined)).toBeUndefined();
expect(parseMode('WRITE')).toBe('write');
for (const invalid of ['', 'unsafe', 'reader']) {
expect(() => parseMode(invalid), invalid).toThrow(/SNOWLUMA_MCP_MODE/);
}
});
it('docs mode exposes catalog tools and local runtime information', () => {
expect(computeTools('docs').map((t) => t.name)).toEqual([
'list_actions',
'get_action',
'search_actions',
'list_categories',
'get_runtime_info',
]);
});
it('read mode adds query_action only (no invoke_action)', () => {
const names = computeTools('read').map((t) => t.name);
expect(names).toContain('query_action');
expect(names).not.toContain('invoke_action');
});
it('write mode adds both query_action and invoke_action', () => {
const names = computeTools('write').map((t) => t.name);
expect(names).toContain('query_action');
expect(names).toContain('invoke_action');
});
it('annotations are honest: query=readOnly, invoke=destructive', () => {
const q = computeTools('write').find((t) => t.name === 'query_action');
const inv = computeTools('write').find((t) => t.name === 'invoke_action');
expect(q?.annotations?.readOnlyHint).toBe(true);
expect(inv?.annotations?.readOnlyHint).toBe(false);
expect(inv?.annotations?.destructiveHint).toBe(true);
});
});
describe('callTool — docs tools (no client needed)', () => {
it('list_actions returns entries that carry readOnly', async () => {
const data = parsed(await callTool('list_actions', {}, { mode: 'docs' }));
expect(data.count).toBeGreaterThan(100);
expect(typeof data.actions[0].readOnly).toBe('boolean');
});
it('get_action resolves a known read-only action', async () => {
const a = parsed(await callTool('get_action', { name: 'get_status' }, { mode: 'docs' }));
expect(a.name).toBe('get_status');
expect(a.readOnly).toBe(true);
});
it('get_action on a write action reports readOnly=false', async () => {
expect(
parsed(await callTool('get_action', { name: 'send_group_msg' }, { mode: 'docs' }))
.readOnly,
).toBe(false);
});
it('get_action accepts an alias', async () => {
// nc_get_rkey is an alias of get_rkey (read-only).
const a = parsed(await callTool('get_action', { name: 'nc_get_rkey' }, { mode: 'docs' }));
expect(a.name).toBe('get_rkey');
});
});
describe('callTool — query_action (read)', () => {
it.each([null, [], 'invalid', 42, false].map((params) => ({ params })))(
'rejects invalid params without invoking the client ($params)',
async ({ params }) => {
let called = false;
const client = fakeClient(() => {
called = true;
return { status: 'ok', retcode: 0, data: null };
});
const res = await callTool(
'query_action',
{ action: 'get_status', params },
{ mode: 'read', client },
);
expect(res.isError).toBe(true);
expect(text(res)).toContain('params');
expect(called).toBe(false);
},
);
it('executes a read-only action and passes the envelope through', async () => {
const res = await callTool(
'query_action',
{ action: 'get_status', params: {} },
{ mode: 'read', client: fakeClient() },
);
expect(res.isError).toBeFalsy();
expect(parsed(res).retcode).toBe(0);
});
it('refuses a write action and points to invoke_action', async () => {
const res = await callTool(
'query_action',
{ action: 'send_group_msg', params: {} },
{ mode: 'read', client: fakeClient() },
);
expect(res.isError).toBe(true);
expect(text(res)).toContain('invoke_action');
});
it('refuses an unknown action', async () => {
const res = await callTool(
'query_action',
{ action: 'no_such_action' },
{ mode: 'read', client: fakeClient() },
);
expect(res.isError).toBe(true);
expect(text(res)).toContain('未知 action');
});
it('is blocked in docs mode even when called directly (defense in depth)', async () => {
const res = await callTool('query_action', { action: 'get_status' }, { mode: 'docs' });
expect(res.isError).toBe(true);
});
it('errors clearly when no client is wired', async () => {
const res = await callTool('query_action', { action: 'get_status' }, { mode: 'read' });
expect(res.isError).toBe(true);
expect(text(res)).toContain('未配置');
});
});
describe('callTool — invoke_action (write)', () => {
it('executes a write action in write mode and forwards action+params', async () => {
let seen: { action: string; params: Record<string, unknown> } | null = null;
const client: ActionClient = {
call: async (action, params) => {
seen = { action, params };
return { status: 'ok', retcode: 0, data: { message_id: 42 } };
},
callStream: async () => {
throw new Error('unexpected stream call');
},
};
const res = await callTool(
'invoke_action',
{ action: 'send_group_msg', params: { group_id: 1, message: 'hi' } },
{ mode: 'write', client },
);
expect(res.isError).toBeFalsy();
expect(seen!.action).toBe('send_group_msg');
expect(seen!.params).toEqual({ group_id: 1, message: 'hi' });
expect(parsed(res).data.message_id).toBe(42);
});
it('is blocked in read mode (write gate cannot be bypassed)', async () => {
const res = await callTool(
'invoke_action',
{ action: 'send_group_msg', params: {} },
{ mode: 'read', client: fakeClient() },
);
expect(res.isError).toBe(true);
expect(text(res)).toContain('写操作未启用');
});
it('passes a retcode≠0 envelope through as data, NOT isError', async () => {
const client = fakeClient(() => ({
status: 'failed',
retcode: 1404,
message: 'not found',
wording: '未找到',
}));
const res = await callTool(
'invoke_action',
{ action: 'send_group_msg', params: {} },
{ mode: 'write', client },
);
expect(res.isError).toBeFalsy();
expect(parsed(res).retcode).toBe(1404);
expect(parsed(res).wording).toBe('未找到');
});
it('maps a transport-level throw to isError', async () => {
const client: ActionClient = {
call: async () => {
throw new Error('connection refused');
},
callStream: async () => {
throw new Error('unexpected stream call');
},
};
const res = await callTool(
'invoke_action',
{ action: 'send_group_msg', params: {} },
{ mode: 'write', client },
);
expect(res.isError).toBe(true);
expect(text(res)).toContain('调用失败');
expect(text(res)).toContain('connection refused');
});
it('accepts a read-only action too (invoke is a superset)', async () => {
const res = await callTool(
'invoke_action',
{ action: 'get_status', params: {} },
{ mode: 'write', client: fakeClient() },
);
expect(res.isError).toBeFalsy();
});
it('refuses an unknown action (typo safety)', async () => {
const res = await callTool(
'invoke_action',
{ action: 'frobnicate' },
{ mode: 'write', client: fakeClient() },
);
expect(res.isError).toBe(true);
expect(text(res)).toContain('未知 action');
});
it('routes catalog Stream Actions through callStream and returns the bounded summary', async () => {
let ordinaryCalls = 0;
let streamCalls = 0;
const terminal = {
status: 'ok',
retcode: 0,
data: { type: 'response', data_type: 'data_complete' },
};
const client: ActionClient = {
call: async () => {
ordinaryCalls++;
return { status: 'ok', retcode: 0 };
},
callStream: async (action, params) => {
streamCalls++;
expect(action).toBe('test_download_stream');
expect(params).toEqual({ error: false });
return { frame_count: 1, frames: [], terminal };
},
};
const res = await callTool(
'invoke_action',
{ action: 'test_download_stream', params: { error: false } },
{ mode: 'write', client },
);
expect(res.isError).toBeFalsy();
expect(parsed(res)).toEqual({ frame_count: 1, frames: [], terminal });
expect(streamCalls).toBe(1);
expect(ordinaryCalls).toBe(0);
});
it('passes execution.input_file only as an MCP upload option, never as OneBot params', async () => {
let seen: { params: Record<string, unknown>; inputFile?: string } | undefined;
const client = fakeClient(undefined, (_action, params, options) => {
seen = { params, inputFile: options?.inputFile };
return {
file_path: '/remote/local.txt',
file_size: 5,
sha256: 'abc',
chunk_count: 1,
stream_id: 'id',
terminal: {
status: 'ok',
retcode: 0,
data: { type: 'response', status: 'file_complete' },
},
};
});
const res = await callTool(
'invoke_action',
{
action: 'upload_file_stream',
params: { file_retention: 0 },
execution: { input_file: '/allowed/local.txt' },
},
{ mode: 'write', client },
);
expect(res.isError).toBeFalsy();
expect(seen).toEqual({ params: { file_retention: 0 }, inputFile: '/allowed/local.txt' });
});
it('forwards MCP request cancellation to Stream Action execution', async () => {
const controller = new AbortController();
let seenSignal: AbortSignal | undefined;
const client = fakeClient(undefined, (_action, _params, options) => {
seenSignal = options?.signal;
return {
frame_count: 1,
frames: [],
terminal: {
status: 'ok',
retcode: 0,
data: { type: 'response', data_type: 'data_complete' },
},
};
});
const res = await callTool(
'invoke_action',
{ action: 'test_download_stream', params: {} },
{
mode: 'write',
client,
signal: controller.signal,
},
);
expect(res.isError).toBeFalsy();
expect(seenSignal).toBe(controller.signal);
});
});
+274
View File
@@ -0,0 +1,274 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { createCallTrace } from '../src/tracing';
import { makeHttpClient } from '../src/client';
import { callTool } from '../src/tools';
const parent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
const directories: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
for (const directory of directories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
function directory(): string {
const value = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-mcp-trace-'));
directories.push(value);
return value;
}
function streamResponse(data: unknown, failed = false): Response {
return new Response(
`${JSON.stringify({ status: failed ? 'failed' : 'ok', retcode: failed ? 100 : 0, data })}\r\n\r\n`,
);
}
describe('tool call trace context', () => {
it('preserves a valid trace ID and sampling flag with a fresh span for every call', () => {
const first = createCallTrace({ traceparent: parent, tracestate: 'vendor=opaque' });
const second = createCallTrace({ traceparent: parent });
expect(first.traceparent).toMatch(/^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/);
expect(first.traceparent).not.toBe(parent);
expect(second.traceparent).not.toBe(first.traceparent);
expect(first.tracestate).toBe('vendor=opaque');
});
it('creates independent nonzero IDs without inbound context and does not copy baggage', () => {
const first = createCallTrace({ baggage: 'private=value' });
const second = createCallTrace();
expect(first.traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-00$/);
expect(first.traceparent.slice(3, 35)).not.toBe('0'.repeat(32));
expect(first.traceparent.slice(36, 52)).not.toBe('0'.repeat(16));
expect(second.traceparent.slice(3, 35)).not.toBe(first.traceparent.slice(3, 35));
expect(first).not.toHaveProperty('baggage');
});
it.each([
null,
123,
parent.toUpperCase(),
parent.replace(/^00/, 'ff'),
parent.replace(parent.slice(3, 35), '0'.repeat(32)),
parent.replace(parent.slice(36, 52), '0'.repeat(16)),
`${parent}-extra`,
`${parent}\n`,
`${parent}\nprivate-secret`,
`${parent},${parent}`,
])('ignores malformed traceparent %j and drops its tracestate', (traceparent) => {
const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const result = createCallTrace({ traceparent, tracestate: 'vendor=private-secret' });
expect(result.traceparent.slice(3, 35)).not.toBe(parent.slice(3, 35));
expect(result.tracestate).toBeUndefined();
expect(diagnostic).toHaveBeenCalledWith(
'[snowluma-mcp] trace metadata ignored: invalid traceparent',
);
expect(JSON.stringify(diagnostic.mock.calls)).not.toContain('private-secret');
});
it('parses future versions using known fields and clears unknown flags', () => {
const result = createCallTrace({ traceparent: `01-${parent.slice(3, 53)}ff-future` });
expect(result.traceparent).toMatch(/^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/);
});
it('accepts W3C tracestate members and normalizes optional whitespace and empty members', () => {
const result = createCallTrace({
traceparent: parent,
tracestate: ' vendor=opaque , , 1tenant@system=value , other=leading space',
});
expect(result.tracestate).toBe('vendor=opaque,1tenant@system=value,other=leading space');
expect(
createCallTrace({ traceparent: parent, tracestate: ' , ' }).tracestate,
).toBeUndefined();
});
it.each([
123,
'vendor=a,vendor=b',
'Vendor=a',
'1vendor=a',
'vendor=',
'vendor=a=b',
'vendor=private\r\nsecret',
'vendor=private\n',
`vendor=${'a'.repeat(257)}`,
Array.from({ length: 33 }, (_, i) => `key${i}=a`).join(','),
`a=${'x'.repeat(256)},b=${'x'.repeat(256)}`,
])('drops invalid tracestate %j without breaking the trace', (tracestate) => {
const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const result = createCallTrace({ traceparent: parent, tracestate });
expect(result.traceparent.slice(3, 35)).toBe(parent.slice(3, 35));
expect(result.tracestate).toBeUndefined();
expect(diagnostic.mock.calls).toEqual([
['[snowluma-mcp] trace metadata ignored: invalid tracestate'],
]);
});
it('drops tracestate without a parent and reports only a fixed diagnosis', () => {
const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => undefined);
expect(createCallTrace({ tracestate: 'vendor=secret' }).tracestate).toBeUndefined();
expect(diagnostic.mock.calls).toEqual([
['[snowluma-mcp] trace metadata ignored: tracestate without valid traceparent'],
]);
});
});
describe('trace propagation through tool execution', () => {
it('isolates concurrent ordinary and stream headers without changing payloads or authentication', async () => {
const calls: Array<{ headers: Headers; body: { action: string; params: unknown } }> = [];
const allStarted = Promise.withResolvers<void>();
const fetcher: typeof fetch = async (_input, init) => {
const body = JSON.parse(String(init?.body));
calls.push({ headers: new Headers(init?.headers), body });
if (calls.length === 3) {
allStarted.resolve();
}
await allStarted.promise;
return body.action === 'test_download_stream'
? streamResponse({ type: 'response', data: 'complete' })
: Response.json({ status: 'ok', retcode: 0, data: { online: true } });
};
const client = makeHttpClient({
endpoint: 'https://unused.invalid',
accessToken: 'credential',
streamDir: directory(),
fetch: fetcher,
});
const first = createCallTrace({ traceparent: parent, tracestate: 'vendor=one' });
const second = createCallTrace();
const outcomes = await Promise.all([
callTool(
'query_action',
{ action: 'get_status', params: { marker: 'first' } },
{ mode: 'read', client, trace: first },
),
callTool(
'invoke_action',
{ action: 'test_download_stream', params: { marker: 'second' } },
{ mode: 'write', client, trace: second },
),
client.call('get_status', { marker: 'untraced' }),
]);
expect(outcomes).toHaveLength(3);
expect(calls[0]?.headers.get('traceparent')).toBe(first.traceparent);
expect(calls[0]?.headers.get('tracestate')).toBe('vendor=one');
expect(calls[1]?.headers.get('traceparent')).toBe(second.traceparent);
expect(calls[1]?.headers.has('tracestate')).toBe(false);
expect(calls[2]?.headers.has('traceparent')).toBe(false);
for (const call of calls) {
expect(call.headers.get('authorization')).toBe('Bearer credential');
expect(call.headers.has('baggage')).toBe(false);
expect(Object.keys(call.body)).toEqual(['action', 'params']);
expect(Object.keys(call.body.params as object)).toEqual(['marker']);
}
});
it('keeps trace metadata outside action permissions and ordinary result envelopes', async () => {
const fetcher = vi.fn<typeof fetch>(async () =>
Response.json({ status: 'failed', retcode: 123, wording: 'denied', data: null }),
);
const client = makeHttpClient({
endpoint: 'https://unused.invalid',
streamDir: directory(),
fetch: fetcher,
});
const trace = createCallTrace();
const denied = await callTool(
'invoke_action',
{ action: 'send_group_msg' },
{ mode: 'read', client, trace },
);
expect(denied.structuredContent.diagnostics.kind).toBe('permission');
expect(fetcher).not.toHaveBeenCalled();
const result = await callTool(
'query_action',
{ action: 'get_status' },
{ mode: 'read', client, trace },
);
expect(result.isError).toBeUndefined();
expect(result.structuredContent.result).toEqual({
status: 'failed',
retcode: 123,
wording: 'denied',
data: null,
});
expect(result.content[0]).toEqual({
type: 'text',
text: JSON.stringify(result.structuredContent.result, null, 2),
});
});
it.each([false, true])(
'uses one call trace for every upload chunk and completion or cleanup (failure: %s)',
async (fail) => {
const root = directory();
const input = path.join(root, 'upload.bin');
const bytes = Buffer.alloc(512 * 1024 + 1, 97);
fs.writeFileSync(input, bytes);
const trace = createCallTrace({ traceparent: parent, tracestate: 'vendor=transfer' });
const calls: Array<{ headers: Headers; params: Record<string, unknown> }> = [];
const fetcher: typeof fetch = async (_input, init) => {
const { params } = JSON.parse(String(init?.body));
calls.push({ headers: new Headers(init?.headers), params });
if (params.reset) {
return streamResponse({ type: 'response' });
}
if (fail && params.chunk_index === 1) {
return streamResponse({ type: 'error' }, true);
}
return streamResponse(
params.is_complete
? {
type: 'response',
status: 'file_complete',
file_path: '/server/upload.bin',
file_size: bytes.length,
sha256: createHash('sha256').update(bytes).digest('hex'),
}
: { type: 'stream', status: 'chunk_received' },
);
};
const client = makeHttpClient({
endpoint: 'https://unused.invalid',
streamDir: root,
uploadRoot: root,
fetch: fetcher,
});
const result = await callTool(
'invoke_action',
{
action: 'upload_file_stream',
execution: { input_file: input },
},
{ mode: 'write', client, trace },
);
expect(result.isError === true).toBe(fail);
expect(calls).toHaveLength(3);
expect(calls[2]?.params[fail ? 'reset' : 'is_complete']).toBe(true);
for (const call of calls) {
expect(call.headers.get('traceparent')).toBe(trace.traceparent);
expect(call.headers.get('tracestate')).toBe(trace.tracestate);
expect(call.params).not.toHaveProperty('traceparent');
expect(call.params).not.toHaveProperty('tracestate');
expect(call.headers.has('baggage')).toBe(false);
}
},
);
});
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"noEmit": false,
"types": ["node"]
},
"include": ["src/**/*"]
}
+17
View File
@@ -0,0 +1,17 @@
// Vitest config — used only to RUN the catalog generator (scripts/gen-catalog.test.ts)
// in an environment where `@snowluma/onebot/action-docs` resolves. The proton
// plugin provides the same bare-specifier resolver @snowluma/onebot's own tests
// use, so importing the action specs works identically here.
import protobufVitePlugin from '@snowluma/proton/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [protobufVitePlugin()],
test: {
include: ['scripts/**/*.test.ts', 'tests/**/*.test.ts'],
environment: 'node',
env: {
SNOWLUMA_LOG_FILE: '0',
},
},
});