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
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:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
@@ -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('无法连接到消息服务。请重新打开预览。'),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
});
|
||||
}
|
||||
@@ -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 中各项诊断;目录版本不等于服务端能力声明。' },
|
||||
);
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
[],
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user