feat(mcp): add compatible protocol workflows and execution services
This commit is contained in:
@@ -77,7 +77,8 @@ Use focused tests while iterating. Before handing off a code change, run the ful
|
||||
- **SDK packaging:** keep the SDK's `NodeNext` build and explicit `.js` relative imports, including type-only exports. Its package tests compile fresh output and check native Node.js imports, consumer declarations, and complete action-map coverage. The SDK action catalog must match OneBot's executable names; `packages/onebot/tests/sdk-actions.test.ts` checks this without adding a runtime dependency on server code. SDK parameter/result contracts remain independent of server types; keep consumer examples under `packages/sdk/tests/fixtures` aligned with the server parser checks in `packages/onebot/tests/sdk-action-params.test.ts`.
|
||||
- **SDK streams:** preserve the single-response client methods and use the additive interfaces in [STREAMING.md](packages/sdk/STREAMING.md) for file transfers. Test framing, cancellation, bounded buffering and incomplete transfers in the SDK; `packages/onebot/tests/sdk-streaming.test.ts` checks real adapter/handler round trips in temporary storage without QQ.
|
||||
- **SDK messaging:** preserve existing string-path and ordinary reply semantics when extending the helpers in [MESSAGING.md](packages/sdk/MESSAGING.md). Filesystem access belongs only in the optional `@snowluma/sdk/node` entry point. Media sends must await upload completion and retain failure context without automatic retries; event waits follow public middleware dispatch and release listeners on every outcome. Keep legacy handler/context types assignable when adding richer SDK-created contexts.
|
||||
- **MCP agents:** preserve existing text results, business-error envelopes, default search matching and mode gates when extending the tools. Additive structured results, discovery controls, runtime checks, cancellation and progress are documented in [AGENT_FEATURES.md](packages/mcp/AGENT_FEATURES.md). Use isolated HTTP and MCP transports in tests; local runtime inspection must not query a live account.
|
||||
- **MCP agents:** preserve existing text results, business-error envelopes, default search matching and mode gates when extending the tools. Use the server SDK's `serveStdio` entry to support modern discovery and legacy initialization from the same command; test both with freshly compiled output. Cache hints belong only on bundled catalog discovery, never execution or account data. Keep trace metadata out of action parameters and sensitive data out of diagnostics. Protocol compatibility, tracing and the additive agent features are documented in [AGENT_FEATURES.md](packages/mcp/AGENT_FEATURES.md). Use isolated HTTP and MCP transports in tests; local runtime inspection must not query a live account.
|
||||
- **MCP execution services:** keep background tasks and event connections opt-in. Persist task state before effects, never replay interrupted writes, and finish durable cleanup before draining the protocol on shutdown. Input continuations must retain their original arguments and execute at most once. Event buffers require explicit account binding, bounded storage and visible gaps. Build the bundled message preview through `packages/mcp/scripts/build-message-app.mjs`; loading a preview must not send a message. Test restart, cancellation and legacy compatibility using isolated transports, including the compiled CLI.
|
||||
- **Protocol codecs:** keep the existing Proton integration in tests so they exercise the same transformations as production. Import real implementations; do not duplicate the algorithm in the test.
|
||||
- **Native coverage:** `pnpm test:native` is separate from default protocol tests and requires compatible native artifacts. A missing artifact or unavailable target is a reported limitation, not permission to bypass the check.
|
||||
- **Live QQ behavior:** fixture tests and a successful API response do not prove delivery or client rendering. State what was tested locally and what still needs a real client.
|
||||
|
||||
@@ -5,6 +5,249 @@ 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
|
||||
@@ -49,8 +292,11 @@ and decimal strings such as media `file_size` exactly as documented.
|
||||
|
||||
## Structured results
|
||||
|
||||
Every tool advertises an `outputSchema` and returns `structuredContent` alongside
|
||||
the existing `content` text block:
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -27,9 +27,9 @@
|
||||
"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",
|
||||
"build": "tsc -p tsconfig.json && node scripts/build-message-app.mjs",
|
||||
"prepack": "pnpm run build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit && tsc -p apps/tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"start": "node dist/server.js"
|
||||
},
|
||||
@@ -41,15 +41,20 @@
|
||||
"qq"
|
||||
],
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.32.0"
|
||||
"@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.11"
|
||||
"vitest": "4.1.11",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@modelcontextprotocol/ext-apps": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.22.1 || >=26.12.1"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { ActionCallError } from './results.js';
|
||||
import type { TraceContext } from './tracing.js';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_STREAM_BYTES = 4 * 1024 * 1024 * 1024;
|
||||
@@ -26,7 +27,7 @@ const MAX_FILE_CHUNKS = 200_000;
|
||||
const RESET_TIMEOUT_MS = 5_000;
|
||||
const MAX_ACTIVE_STREAMS = 4;
|
||||
const INTERNAL_STREAM_CALL = Symbol('snowluma.mcp.internalStreamCall');
|
||||
const MCP_UPLOAD_OWNED_FIELDS = [
|
||||
export const MCP_UPLOAD_OWNED_FIELDS = [
|
||||
'stream_id',
|
||||
'chunk_data',
|
||||
'chunk_index',
|
||||
@@ -135,6 +136,18 @@ export interface TransferProgress {
|
||||
|
||||
export interface CallOptions {
|
||||
signal?: AbortSignal;
|
||||
trace?: TraceContext;
|
||||
}
|
||||
|
||||
function requestHeaders(
|
||||
base: Record<string, string>,
|
||||
trace?: TraceContext,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...base,
|
||||
...(trace ? { traceparent: trace.traceparent } : {}),
|
||||
...(trace?.tracestate ? { tracestate: trace.tracestate } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface StreamCallOptions extends CallOptions {
|
||||
@@ -733,7 +746,7 @@ export function makeHttpClient(opts: HttpClientOptions): ActionClient {
|
||||
signal.throwIfAborted();
|
||||
const res = await fetchImpl(opts.endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: requestHeaders(headers, options?.trace),
|
||||
body: JSON.stringify({ action, params }),
|
||||
signal,
|
||||
});
|
||||
@@ -842,6 +855,7 @@ export function makeHttpClient(opts: HttpClientOptions): ActionClient {
|
||||
(uploadParams, signal) =>
|
||||
client.callStream(action, uploadParams, {
|
||||
...(signal ? { signal } : {}),
|
||||
...(options.trace ? { trace: options.trace } : {}),
|
||||
[INTERNAL_STREAM_CALL]: true,
|
||||
} as InternalStreamCallOptions),
|
||||
options.onProgress,
|
||||
@@ -867,7 +881,7 @@ export function makeHttpClient(opts: HttpClientOptions): ActionClient {
|
||||
throwIfCancelled(options?.signal, action, 'connection');
|
||||
res = await fetchImpl(opts.endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: requestHeaders(headers, options?.trace),
|
||||
body: JSON.stringify({ action, params }),
|
||||
signal: requestSignal,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { CallToolResult, Tool } from '@modelcontextprotocol/server';
|
||||
import type { OneBotEnvelope } from './client.js';
|
||||
|
||||
export type FailureKind =
|
||||
|
||||
@@ -1,52 +1,336 @@
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
Server,
|
||||
ProtocolError,
|
||||
isInputRequiredResult,
|
||||
isSpecType,
|
||||
type ServerContext,
|
||||
type Tool,
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { ACTIONS, CATEGORIES } from './generated/catalog.js';
|
||||
import { callTool, computeTools, type CallCtx } from './tools.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: Pick<CallCtx, 'mode' | 'client'>): Server {
|
||||
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: {} } },
|
||||
{
|
||||
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' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: computeTools(ctx.mode),
|
||||
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(CallToolRequestSchema, async (req, extra) => {
|
||||
const token = req.params._meta?.progressToken;
|
||||
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) =>
|
||||
extra.sendNotification({
|
||||
mcpReq.notify({
|
||||
method: 'notifications/progress',
|
||||
params: { progressToken: token, ...value },
|
||||
}),
|
||||
extra.signal,
|
||||
mcpReq.signal,
|
||||
);
|
||||
const result = await callTool(req.params.name, req.params.arguments ?? {}, {
|
||||
...ctx,
|
||||
signal: extra.signal,
|
||||
...(progress ? { onProgress: progress.report } : {}),
|
||||
});
|
||||
|
||||
if (!result.isError) {
|
||||
await progress?.finish();
|
||||
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;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
||||
server.setRequestHandler('resources/list', async (_req, request) => ({
|
||||
resources: [
|
||||
{
|
||||
uri: RESOURCE_URI,
|
||||
@@ -54,14 +338,28 @@ export function createMcpServer(ctx: Pick<CallCtx, 'mode' | 'client'>): Server {
|
||||
mimeType: 'application/json',
|
||||
description: `SnowLuma v${VERSION} 的 ${ACTIONS.length} 个 OneBot action 完整目录(文档 + JSON Schema)。`,
|
||||
},
|
||||
...messageAppResources(supportsMessageApp(capabilities(request))),
|
||||
...(events?.resources() ?? []),
|
||||
],
|
||||
}));
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
||||
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,
|
||||
@@ -75,5 +373,28 @@ export function createMcpServer(ctx: Pick<CallCtx, 'mode' | 'client'>): Server {
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -20,10 +20,13 @@
|
||||
// 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 { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { serveStdio, StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { ACTIONS, CATEGORIES } from './generated/catalog.js';
|
||||
import {
|
||||
makeHttpClient,
|
||||
@@ -33,7 +36,11 @@ import {
|
||||
} from './client.js';
|
||||
import { parseMode, type Mode } from './tools.js';
|
||||
|
||||
import { createMcpServer } from './server-factory.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 ────────────────────────────
|
||||
@@ -74,13 +81,95 @@ function resolveRuntime(): { mode: Mode; client?: ActionClient } {
|
||||
return { mode, client };
|
||||
}
|
||||
|
||||
const { mode, client } = resolveRuntime();
|
||||
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();
|
||||
|
||||
const server = createMcpServer({ mode, client });
|
||||
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();
|
||||
await server.connect(transport);
|
||||
const where = client ? ` → ${process.env.SNOWLUMA_MCP_ENDPOINT?.trim()}` : '';
|
||||
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}${where}`,
|
||||
`[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 {};
|
||||
});
|
||||
}
|
||||
@@ -8,10 +8,11 @@
|
||||
// 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/sdk/types.js';
|
||||
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,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} 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';
|
||||
|
||||
@@ -186,6 +188,7 @@ export interface CallCtx {
|
||||
mode: Mode;
|
||||
client?: ActionClient;
|
||||
signal?: AbortSignal;
|
||||
trace?: TraceContext;
|
||||
onProgress?: (progress: TransferProgress) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -232,13 +235,21 @@ export async function callTool(
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared execution path for query_action (read) / invoke_action (write).
|
||||
* Re-checks mode + readOnly routing regardless of which tools were listed. */
|
||||
async function execute(
|
||||
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',
|
||||
): Promise<ToolResult> {
|
||||
): PreparedActionCall | ToolResult {
|
||||
if (kind === 'write' && ctx.mode !== 'write') {
|
||||
return asError(
|
||||
'写操作未启用:需设置 SNOWLUMA_MCP_MODE=write。',
|
||||
@@ -321,22 +332,54 @@ async function execute(
|
||||
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 ctx.client.callStream(action, params, {
|
||||
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 ctx.client.call(
|
||||
const result = await client.call(
|
||||
action,
|
||||
params,
|
||||
ctx.signal ? { signal: ctx.signal } : undefined,
|
||||
ctx.signal || ctx.trace
|
||||
? {
|
||||
...(ctx.signal ? { signal: ctx.signal } : {}),
|
||||
...(ctx.trace ? { trace: ctx.trace } : {}),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
return asText(result, envelopeDiagnostics(result));
|
||||
} catch (err) {
|
||||
|
||||
@@ -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,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(
|
||||
[],
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -40,6 +40,33 @@ function fakeOneBot(): ActionClient {
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -48,7 +75,10 @@ describe('MCP protocol compatibility', () => {
|
||||
expect(tool.outputSchema).toBeDefined();
|
||||
const result = await client.callTool({
|
||||
name: tool.name,
|
||||
arguments: { name: 'get_status', query: 'history', action: 'get_status' },
|
||||
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' } });
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import { createLogger, runWithTraceRequest } from '@snowluma/common/logger';
|
||||
import { WebSocketServer } from '@snowluma/websocket';
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http';
|
||||
import type { ApiHandler } from '../api-handler';
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { IOneBotNetworkAdapter, type AdapterStatus, type NetworkAdapterContext } from './adapter';
|
||||
import { isAuthorized, normalizePath } from './utils';
|
||||
import { WsServerConnections } from './ws-server-connections';
|
||||
import { httpTraceparent } from './http-trace';
|
||||
|
||||
const moduleLog = createLogger('OneBot.HTTP');
|
||||
|
||||
@@ -345,7 +346,11 @@ export class HttpServerAdapter extends IOneBotNetworkAdapter<HttpServerNetwork>
|
||||
});
|
||||
}
|
||||
|
||||
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
private handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
return runWithTraceRequest(() => this.handleRequestInContext(req, res));
|
||||
}
|
||||
|
||||
private async handleRequestInContext(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const apiQuiescedAtIngress = this.ctx.api.isAcceptingActions === false;
|
||||
|
||||
if (apiQuiescedAtIngress) {
|
||||
@@ -382,6 +387,16 @@ export class HttpServerAdapter extends IOneBotNetworkAdapter<HttpServerNetwork>
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.headers.traceparent !== undefined) {
|
||||
const traceparent = httpTraceparent(req.headers.traceparent);
|
||||
|
||||
if (traceparent) {
|
||||
this.log.trace('http_trace traceparent=%s', traceparent);
|
||||
} else {
|
||||
this.log.trace('http_trace ignored invalid traceparent');
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && !action) {
|
||||
respond(200, { status: 'ok', retcode: 0, data: { online: true } });
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Accept W3C traceparent without treating caller-supplied IDs as local request IDs. */
|
||||
export function httpTraceparent(value: unknown): 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;
|
||||
}
|
||||
|
||||
// Future-version fields are opaque and must not enter application logs.
|
||||
return value.slice(0, 55);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import {
|
||||
currentRequestId,
|
||||
getLogLevel,
|
||||
setLogLevel,
|
||||
subscribeLogs,
|
||||
type LogEntry,
|
||||
} from '@snowluma/common/logger';
|
||||
import { HttpServerAdapter } from '../src/network/http-server-adapter';
|
||||
import { httpTraceparent } from '../src/network/http-trace';
|
||||
import { defineStreamAction } from '../src/action-kit';
|
||||
import { createCompiledTestHandler, testAction } from './helpers/compiled-action-handler';
|
||||
import type { ApiHandler } from '../src/api-handler';
|
||||
|
||||
const first = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
|
||||
const second = '00-12345678901234567890123456789012-1234567890123456-00';
|
||||
|
||||
describe('HTTP traceparent boundary', () => {
|
||||
it('accepts defined fields of future versions without logging their opaque extension', () => {
|
||||
expect(httpTraceparent(first)).toBe(first);
|
||||
expect(httpTraceparent(`01-${first.slice(3)}-opaque`)).toBe(`01-${first.slice(3)}`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
[first, second],
|
||||
first.toUpperCase(),
|
||||
first.replace(/^00/, 'ff'),
|
||||
first.replace(first.slice(3, 35), '0'.repeat(32)),
|
||||
first.replace(first.slice(36, 52), '0'.repeat(16)),
|
||||
`${first}-extra`,
|
||||
`${first}\n`,
|
||||
`${first},${second}`,
|
||||
`${first}\nprivate`,
|
||||
])('rejects malformed or ambiguous header %j', (value) => {
|
||||
expect(httpTraceparent(value)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP distributed trace correlation', () => {
|
||||
let adapter: HttpServerAdapter | undefined;
|
||||
let level: string;
|
||||
let entries: LogEntry[];
|
||||
let unsubscribe: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
level = getLogLevel();
|
||||
setLogLevel('trace');
|
||||
entries = [];
|
||||
unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await adapter?.close();
|
||||
adapter = undefined;
|
||||
unsubscribe();
|
||||
setLogLevel(level);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function open(api: ApiHandler): Promise<string> {
|
||||
adapter = new HttpServerAdapter(
|
||||
'http-tracing',
|
||||
{
|
||||
name: 'http-tracing',
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
path: '/',
|
||||
accessToken: 'test-token',
|
||||
messageFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
},
|
||||
{
|
||||
uin: '10001',
|
||||
api,
|
||||
buildLifecycleEvent: () => ({}),
|
||||
buildHeartbeatEvent: () => ({}),
|
||||
},
|
||||
);
|
||||
await adapter.open();
|
||||
const server = (adapter as unknown as { server: Server }).server;
|
||||
return `http://127.0.0.1:${(server.address() as AddressInfo).port}/`;
|
||||
}
|
||||
|
||||
function send(endpoint: string, action: string, traceparent: string, token = 'test-token') {
|
||||
return fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
traceparent,
|
||||
tracestate: 'vendor=private-state',
|
||||
baggage: 'private-baggage',
|
||||
},
|
||||
body: JSON.stringify({ action, params: {} }),
|
||||
});
|
||||
}
|
||||
|
||||
it('links concurrent HTTP traces to each action lifecycle without changing local request IDs', async () => {
|
||||
const inside = new Map<string, number | undefined>();
|
||||
const started = Promise.withResolvers<void>();
|
||||
let count = 0;
|
||||
const api = createCompiledTestHandler(
|
||||
{} as never,
|
||||
['trace_first', 'trace_second'].map((name) =>
|
||||
testAction(name, async () => {
|
||||
inside.set(name, currentRequestId());
|
||||
count++;
|
||||
|
||||
if (count === 2) {
|
||||
started.resolve();
|
||||
}
|
||||
|
||||
await started.promise;
|
||||
expect(currentRequestId()).toBe(inside.get(name));
|
||||
return { status: 'ok', retcode: 0, data: { name } };
|
||||
}),
|
||||
),
|
||||
);
|
||||
const endpoint = await open(api);
|
||||
const responses = await Promise.all([
|
||||
send(endpoint, 'trace_first', first),
|
||||
send(endpoint, 'trace_second', second),
|
||||
]);
|
||||
expect(await responses[0]!.json()).toEqual({
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: { name: 'trace_first' },
|
||||
});
|
||||
expect(await responses[1]!.json()).toEqual({
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: { name: 'trace_second' },
|
||||
});
|
||||
expect(inside.get('trace_first')).toBeTypeOf('number');
|
||||
expect(inside.get('trace_second')).not.toBe(inside.get('trace_first'));
|
||||
|
||||
for (const [name, traceparent] of [
|
||||
['trace_first', first],
|
||||
['trace_second', second],
|
||||
]) {
|
||||
const correlation = entries.find(
|
||||
(entry) => entry.message === `http_trace traceparent=${traceparent}`,
|
||||
);
|
||||
expect(correlation?.req).toBe(inside.get(name!));
|
||||
const lifecycle = entries.filter(
|
||||
(entry) =>
|
||||
entry.scope === 'Bridge.Action' && entry.message.includes(`action=${name}`),
|
||||
);
|
||||
expect(lifecycle).toHaveLength(2);
|
||||
expect(lifecycle.every((entry) => entry.req === correlation?.req)).toBe(true);
|
||||
}
|
||||
|
||||
const correlationText = entries
|
||||
.filter((entry) => entry.message.startsWith('http_trace'))
|
||||
.map((entry) => entry.message)
|
||||
.join('\n');
|
||||
expect(correlationText).not.toMatch(/private-state|private-baggage/);
|
||||
});
|
||||
|
||||
it('uses the same request context for streamed action frames and completion', async () => {
|
||||
const api = createCompiledTestHandler({} as never, [
|
||||
defineStreamAction({
|
||||
name: 'trace_stream_http',
|
||||
params: {},
|
||||
run: async (_params, _ctx, sink) => {
|
||||
await sink.send({ type: 'stream', data: 'chunk' });
|
||||
return { status: 'ok', retcode: 0, data: { type: 'response' } };
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const endpoint = await open(api);
|
||||
const response = await send(endpoint, 'trace_stream_http', first);
|
||||
const wire = await response.text();
|
||||
expect(response.status).toBe(200);
|
||||
expect(wire).toContain('chunk');
|
||||
const correlation = entries.find(
|
||||
(entry) => entry.message === `http_trace traceparent=${first}`,
|
||||
);
|
||||
const lifecycle = entries.filter(
|
||||
(entry) =>
|
||||
entry.scope === 'Bridge.Action' &&
|
||||
entry.message.includes('action=trace_stream_http'),
|
||||
);
|
||||
expect(lifecycle.length).toBeGreaterThanOrEqual(3);
|
||||
expect(lifecycle.every((entry) => entry.req === correlation?.req)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores invalid trace input without changing authorization or leaking it into logs', async () => {
|
||||
const run = vi.fn(async () => ({ status: 'ok' as const, retcode: 0, data: null }));
|
||||
const api = createCompiledTestHandler({} as never, [testAction('trace_guard', run)]);
|
||||
const endpoint = await open(api);
|
||||
const rejected = await send(endpoint, 'trace_guard', first, 'wrong-token');
|
||||
expect(rejected.status).toBe(401);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(entries.some((entry) => entry.message.startsWith('http_trace'))).toBe(false);
|
||||
const accepted = await send(endpoint, 'trace_guard', 'private-invalid-trace');
|
||||
expect(await accepted.json()).toEqual({ status: 'ok', retcode: 0, data: null });
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
entries.some((entry) => entry.message === 'http_trace ignored invalid traceparent'),
|
||||
).toBe(true);
|
||||
expect(entries.map((entry) => entry.message).join('\n')).not.toMatch(
|
||||
/private-invalid-trace|private-state|private-baggage/,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not allocate a local request ID when TRACE logging is disabled', async () => {
|
||||
setLogLevel('info');
|
||||
let requestId: number | undefined;
|
||||
const api = createCompiledTestHandler({} as never, [
|
||||
testAction('trace_disabled', async () => {
|
||||
requestId = currentRequestId();
|
||||
return { status: 'ok', retcode: 0, data: null };
|
||||
}),
|
||||
]);
|
||||
const response = await send(await open(api), 'trace_disabled', first);
|
||||
expect(response.status).toBe(200);
|
||||
await response.text();
|
||||
expect(requestId).toBeUndefined();
|
||||
expect(entries.some((entry) => entry.message.startsWith('http_trace'))).toBe(false);
|
||||
});
|
||||
});
|
||||
Generated
+97
-1
@@ -203,10 +203,22 @@ importers:
|
||||
|
||||
packages/mcp:
|
||||
dependencies:
|
||||
'@modelcontextprotocol/server':
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
ws:
|
||||
specifier: ^8.21.0
|
||||
version: 8.22.0
|
||||
devDependencies:
|
||||
'@modelcontextprotocol/client':
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
'@modelcontextprotocol/ext-apps':
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3(@modelcontextprotocol/client@2.3.1)(@modelcontextprotocol/core@2.3.1)(@modelcontextprotocol/server@2.3.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6)
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.32.0
|
||||
version: 1.32.1(supports-color@7.2.0)(zod@4.3.6)
|
||||
devDependencies:
|
||||
'@snowluma/onebot':
|
||||
specifier: workspace:*
|
||||
version: link:../onebot
|
||||
@@ -216,6 +228,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 24.19.2
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.2
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.2
|
||||
@@ -918,6 +933,32 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@modelcontextprotocol/client@2.3.1':
|
||||
resolution: {integrity: sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@modelcontextprotocol/core@2.3.1':
|
||||
resolution: {integrity: sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@modelcontextprotocol/ext-apps@2.0.3':
|
||||
resolution: {integrity: sha512-Tn+4+cyhO4f4cQSDxNyLiIfEE+qlTTuIbWBJB8JTinEXOhDYgCT4wDTQplxD9MBYZH5pCYGC59GaxiwIT4bu8w==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@modelcontextprotocol/client': ^2.0.0
|
||||
'@modelcontextprotocol/core': ^2.0.0
|
||||
'@modelcontextprotocol/server': ^2.0.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
zod: ^4.2.0
|
||||
peerDependenciesMeta:
|
||||
'@modelcontextprotocol/server':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
'@modelcontextprotocol/sdk@1.32.1':
|
||||
resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -928,6 +969,10 @@ packages:
|
||||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@modelcontextprotocol/server@2.3.1':
|
||||
resolution: {integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6':
|
||||
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||
peerDependencies:
|
||||
@@ -2025,6 +2070,9 @@ packages:
|
||||
'@types/semver@7.7.1':
|
||||
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
||||
|
||||
'@types/ws@8.18.2':
|
||||
resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.58.2':
|
||||
resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -3576,6 +3624,18 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
ws@8.22.0:
|
||||
resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
@@ -3917,6 +3977,31 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@modelcontextprotocol/client@2.3.1':
|
||||
dependencies:
|
||||
'@modelcontextprotocol/core': 2.3.1
|
||||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.1.0
|
||||
jose: 6.2.3
|
||||
pkce-challenge: 5.0.1
|
||||
zod: 4.3.6
|
||||
|
||||
'@modelcontextprotocol/core@2.3.1':
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
'@modelcontextprotocol/ext-apps@2.0.3(@modelcontextprotocol/client@2.3.1)(@modelcontextprotocol/core@2.3.1)(@modelcontextprotocol/server@2.3.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@modelcontextprotocol/client': 2.3.1
|
||||
'@modelcontextprotocol/core': 2.3.1
|
||||
'@standard-schema/spec': 1.1.0
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
'@modelcontextprotocol/server': 2.3.1
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
|
||||
'@modelcontextprotocol/sdk@1.32.1(supports-color@7.2.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.17(hono@4.13.13)
|
||||
@@ -3939,6 +4024,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@modelcontextprotocol/server@2.3.1':
|
||||
dependencies:
|
||||
'@modelcontextprotocol/core': 2.3.1
|
||||
zod: 4.3.6
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.1
|
||||
@@ -4846,6 +4936,10 @@ snapshots:
|
||||
|
||||
'@types/semver@7.7.1': {}
|
||||
|
||||
'@types/ws@8.18.2':
|
||||
dependencies:
|
||||
'@types/node': 24.19.2
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
@@ -6403,6 +6497,8 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.22.0: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yallist@5.0.0: {}
|
||||
|
||||
Reference in New Issue
Block a user