@zerotal/arch 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed blocks: how a generated file stays generated without eating what
|
|
3
|
+
* someone wrote around it.
|
|
4
|
+
*
|
|
5
|
+
* `arch:update` runs on every framework upgrade, and the file it rewrites is one
|
|
6
|
+
* a developer is invited to edit — that is the point of `AGENTS.md`. So the
|
|
7
|
+
* generated region is fenced, and only the region is ever replaced. Text above
|
|
8
|
+
* it, below it, and any second block someone added by hand all survive.
|
|
9
|
+
*
|
|
10
|
+
* A file whose markers are damaged is left completely alone and reported as a
|
|
11
|
+
* conflict. Guessing where the block was meant to end is how a tool deletes a
|
|
12
|
+
* paragraph nobody backed up.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const BLOCK_START = "<!-- zerotal:arch:start -->";
|
|
16
|
+
export const BLOCK_END = "<!-- zerotal:arch:end -->";
|
|
17
|
+
|
|
18
|
+
export type BlockOutcome =
|
|
19
|
+
/** The file did not exist, or had no block: one was added. */
|
|
20
|
+
| { status: "created"; text: string }
|
|
21
|
+
/** The block was there and its contents changed. */
|
|
22
|
+
| { status: "updated"; text: string }
|
|
23
|
+
/** The block was there and already said this. */
|
|
24
|
+
| { status: "unchanged"; text: string }
|
|
25
|
+
/** The markers are damaged; nothing was written. */
|
|
26
|
+
| { status: "conflict"; reason: string };
|
|
27
|
+
|
|
28
|
+
/** Wrap generated content in its markers. */
|
|
29
|
+
export function fence(content: string): string {
|
|
30
|
+
return `${BLOCK_START}\n${content.trim()}\n${BLOCK_END}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Put `content` into `existing`'s managed block.
|
|
35
|
+
*
|
|
36
|
+
* @param existing - The file's current text, or `undefined` when there is none.
|
|
37
|
+
* @param content - The generated body, unfenced.
|
|
38
|
+
* @param preamble - Written above the block, once, only when creating the file.
|
|
39
|
+
* Never rewritten afterwards — it is the developer's from that moment on.
|
|
40
|
+
*/
|
|
41
|
+
export function applyBlock(
|
|
42
|
+
existing: string | undefined,
|
|
43
|
+
content: string,
|
|
44
|
+
preamble = "",
|
|
45
|
+
): BlockOutcome {
|
|
46
|
+
const block = fence(content);
|
|
47
|
+
|
|
48
|
+
if (existing === undefined || existing.trim().length === 0) {
|
|
49
|
+
const head = preamble.trim().length > 0 ? `${preamble.trim()}\n\n` : "";
|
|
50
|
+
return { status: "created", text: `${head}${block}\n` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const start = existing.indexOf(BLOCK_START);
|
|
54
|
+
const end = existing.indexOf(BLOCK_END);
|
|
55
|
+
|
|
56
|
+
if (start === -1 && end === -1) {
|
|
57
|
+
// A file that predates this tool. Append rather than prepend: whatever is
|
|
58
|
+
// already at the top is what its author chose to say first.
|
|
59
|
+
return { status: "created", text: `${existing.trimEnd()}\n\n${block}\n` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (start === -1 || end === -1 || end < start) {
|
|
63
|
+
return {
|
|
64
|
+
status: "conflict",
|
|
65
|
+
reason:
|
|
66
|
+
start === -1
|
|
67
|
+
? `found ${BLOCK_END} with no opening marker`
|
|
68
|
+
: end === -1
|
|
69
|
+
? `found ${BLOCK_START} with no closing marker`
|
|
70
|
+
: "the closing marker comes before the opening one",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const text = existing.slice(0, start) + block + existing.slice(end + BLOCK_END.length);
|
|
75
|
+
|
|
76
|
+
return text === existing ? { status: "unchanged", text } : { status: "updated", text };
|
|
77
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registering this server in an MCP client's config.
|
|
3
|
+
*
|
|
4
|
+
* The file belongs to the project, not to us: it may already list three other
|
|
5
|
+
* servers, and it may have been hand-edited. So the write is a merge of one key
|
|
6
|
+
* into whatever is there, and every other key comes through byte-for-byte
|
|
7
|
+
* intact — including ones no schema this package knows about.
|
|
8
|
+
*
|
|
9
|
+
* A file that is not valid JSON is reported, not overwritten. There is no
|
|
10
|
+
* version of "I could not read your config so I replaced it" that is the right
|
|
11
|
+
* thing to do.
|
|
12
|
+
*/
|
|
13
|
+
import type { McpTarget } from "./detect.ts";
|
|
14
|
+
|
|
15
|
+
/** Where the server lives, relative to the project root. */
|
|
16
|
+
export const SERVER_ENTRY_PATH = "node_modules/@zerotal/arch/src/bin/mcp.ts";
|
|
17
|
+
|
|
18
|
+
export type ConfigOutcome =
|
|
19
|
+
| { status: "created" | "updated" | "unchanged"; text: string }
|
|
20
|
+
| { status: "conflict"; reason: string };
|
|
21
|
+
|
|
22
|
+
/** The server entry an MCP client is given. */
|
|
23
|
+
export function serverEntry(): Record<string, unknown> {
|
|
24
|
+
return {
|
|
25
|
+
// `bun`, not `bunx`: the app runs on Bun and so must its agent surface,
|
|
26
|
+
// and a bare command resolves through PATH to whatever is first.
|
|
27
|
+
command: "bun",
|
|
28
|
+
args: [SERVER_ENTRY_PATH],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Merge this server into an MCP config document.
|
|
34
|
+
*
|
|
35
|
+
* @param existing - The file's current text, or `undefined` when there is none.
|
|
36
|
+
* @param name - The key to register under.
|
|
37
|
+
* @param target - Which client's file this is; decides the container key.
|
|
38
|
+
*/
|
|
39
|
+
export function applyMcpConfig(
|
|
40
|
+
existing: string | undefined,
|
|
41
|
+
name: string,
|
|
42
|
+
target: McpTarget,
|
|
43
|
+
): ConfigOutcome {
|
|
44
|
+
let document: Record<string, unknown> = {};
|
|
45
|
+
const isNew = existing === undefined || existing.trim().length === 0;
|
|
46
|
+
|
|
47
|
+
if (!isNew) {
|
|
48
|
+
let parsed: unknown;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(existing);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return {
|
|
53
|
+
status: "conflict",
|
|
54
|
+
reason: `${target.path} is not valid JSON (${describe(error)}) — left untouched.`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
58
|
+
return {
|
|
59
|
+
status: "conflict",
|
|
60
|
+
reason: `${target.path} is not a JSON object — left untouched.`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
document = parsed as Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const container = document[target.key];
|
|
67
|
+
const servers: Record<string, unknown> =
|
|
68
|
+
typeof container === "object" && container !== null && !Array.isArray(container)
|
|
69
|
+
? { ...(container as Record<string, unknown>) }
|
|
70
|
+
: {};
|
|
71
|
+
|
|
72
|
+
servers[name] = serverEntry();
|
|
73
|
+
const text = JSON.stringify({ ...document, [target.key]: servers }, null, 2) + "\n";
|
|
74
|
+
|
|
75
|
+
if (isNew) return { status: "created", text };
|
|
76
|
+
return text === existing ? { status: "unchanged", text } : { status: "updated", text };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function describe(error: unknown): string {
|
|
80
|
+
return error instanceof Error ? error.message : String(error);
|
|
81
|
+
}
|
package/src/mcp/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport half of `@zerotal/arch`, exported on its own subpath.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here knows what a Zerotal app is. It is published separately from the
|
|
5
|
+
* root barrel so a package can build its own MCP server over these pieces —
|
|
6
|
+
* hand {@link McpServer} an array of {@link ArchTool} and serve it — without
|
|
7
|
+
* pulling in the tools, the probe command, or the vendored docs.
|
|
8
|
+
*/
|
|
9
|
+
export { McpServer } from "./server.ts";
|
|
10
|
+
export type { McpServerOptions } from "./server.ts";
|
|
11
|
+
export { serveStdio } from "./stdio.ts";
|
|
12
|
+
export type { StdioOptions } from "./stdio.ts";
|
|
13
|
+
export { decodeFrame, encodeFrame, failure, success } from "./jsonrpc.ts";
|
|
14
|
+
export type { DecodedFrame } from "./jsonrpc.ts";
|
|
15
|
+
export { LEGACY_VERSIONS, Meta, MODERN_VERSION, RpcError, SUPPORTED_VERSIONS } from "./types.ts";
|
|
16
|
+
export type {
|
|
17
|
+
ArchTool,
|
|
18
|
+
Era,
|
|
19
|
+
JsonRpcFailure,
|
|
20
|
+
JsonRpcId,
|
|
21
|
+
JsonRpcRequest,
|
|
22
|
+
JsonRpcResponse,
|
|
23
|
+
JsonRpcSuccess,
|
|
24
|
+
JsonSchema,
|
|
25
|
+
ServerIdentity,
|
|
26
|
+
ToolOutcome,
|
|
27
|
+
} from "./types.ts";
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-RPC 2.0 framing for the stdio binding.
|
|
3
|
+
*
|
|
4
|
+
* The wire format is one JSON message per line, and the spec states the
|
|
5
|
+
* constraint as a hard MUST NOT: a message may not contain an embedded newline.
|
|
6
|
+
* `JSON.stringify` already escapes newlines inside strings, so the rule holds by
|
|
7
|
+
* construction — {@link encodeFrame} asserts it anyway, because the one thing
|
|
8
|
+
* that must never happen here is a half-message splitting a client's parser and
|
|
9
|
+
* desynchronising every frame after it.
|
|
10
|
+
*
|
|
11
|
+
* Decoding never throws. A line that is not JSON, or is JSON but not a request,
|
|
12
|
+
* comes back as a ready-to-send failure rather than an exception — a malformed
|
|
13
|
+
* frame is a thing to answer, not a reason for the server to stop reading.
|
|
14
|
+
*/
|
|
15
|
+
import { RpcError } from "./types.ts";
|
|
16
|
+
import type { JsonRpcFailure, JsonRpcId, JsonRpcRequest, JsonRpcResponse } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
/** The result of reading one line: either a request to dispatch, or a reply to send. */
|
|
19
|
+
export type DecodedFrame =
|
|
20
|
+
{ ok: true; request: JsonRpcRequest } | { ok: false; failure: JsonRpcFailure };
|
|
21
|
+
|
|
22
|
+
/** Build a JSON-RPC success reply. */
|
|
23
|
+
export function success(id: JsonRpcId, result: Record<string, unknown>): JsonRpcResponse {
|
|
24
|
+
return { jsonrpc: "2.0", id, result };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a JSON-RPC error reply.
|
|
29
|
+
*
|
|
30
|
+
* `id` is `null` only when the inbound frame was too broken to carry one — that
|
|
31
|
+
* is the one case JSON-RPC allows it, and conflating it with a real id would
|
|
32
|
+
* make a client correlate the failure to the wrong request.
|
|
33
|
+
*/
|
|
34
|
+
export function failure(
|
|
35
|
+
id: JsonRpcId | null,
|
|
36
|
+
code: number,
|
|
37
|
+
message: string,
|
|
38
|
+
data?: unknown,
|
|
39
|
+
): JsonRpcFailure {
|
|
40
|
+
return {
|
|
41
|
+
jsonrpc: "2.0",
|
|
42
|
+
id,
|
|
43
|
+
error: { code, message, ...(data !== undefined ? { data } : {}) },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An id is usable if JSON-RPC allows it there: a string or a number, never anything else. */
|
|
52
|
+
function readId(value: unknown): JsonRpcId | undefined {
|
|
53
|
+
if (typeof value === "string") return value;
|
|
54
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Turn one line of input into a request, or into the failure to answer it with.
|
|
60
|
+
*
|
|
61
|
+
* Blank lines return `undefined` — clients and shells both emit them, and
|
|
62
|
+
* answering a blank line with a parse error would be noise rather than help.
|
|
63
|
+
*/
|
|
64
|
+
export function decodeFrame(line: string): DecodedFrame | undefined {
|
|
65
|
+
const trimmed = line.trim();
|
|
66
|
+
if (trimmed.length === 0) return undefined;
|
|
67
|
+
|
|
68
|
+
let parsed: unknown;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(trimmed);
|
|
71
|
+
} catch {
|
|
72
|
+
return { ok: false, failure: failure(null, RpcError.PARSE, "Invalid JSON.") };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Batches were never part of the stdio binding's message rules — one message
|
|
76
|
+
// per line — and the modern revision does not reinstate them.
|
|
77
|
+
if (Array.isArray(parsed)) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
failure: failure(null, RpcError.INVALID_REQUEST, "Batched requests are not supported."),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (!isPlainObject(parsed)) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
failure: failure(null, RpcError.INVALID_REQUEST, "A message must be a JSON object."),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const id = readId(parsed["id"]);
|
|
91
|
+
if (parsed["jsonrpc"] !== "2.0") {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
failure: failure(id ?? null, RpcError.INVALID_REQUEST, 'Expected "jsonrpc": "2.0".'),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const method = parsed["method"];
|
|
99
|
+
if (typeof method !== "string" || method.length === 0) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
failure: failure(id ?? null, RpcError.INVALID_REQUEST, "Missing method."),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const rawParams = parsed["params"];
|
|
107
|
+
const params = isPlainObject(rawParams) ? rawParams : undefined;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
request: {
|
|
112
|
+
jsonrpc: "2.0",
|
|
113
|
+
method,
|
|
114
|
+
...(id !== undefined ? { id } : {}),
|
|
115
|
+
...(params !== undefined ? { params } : {}),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Serialise one message to its wire line, newline included.
|
|
122
|
+
*
|
|
123
|
+
* @throws If the encoded form contains a raw newline. Unreachable through
|
|
124
|
+
* `JSON.stringify`, and asserted rather than trusted because a frame that
|
|
125
|
+
* splits across two lines corrupts the stream from that point on.
|
|
126
|
+
*/
|
|
127
|
+
export function encodeFrame(message: JsonRpcResponse): string {
|
|
128
|
+
const encoded = JSON.stringify(message);
|
|
129
|
+
if (encoded.includes("\n") || encoded.includes("\r")) {
|
|
130
|
+
throw new Error("Encoded JSON-RPC frame contains a newline.");
|
|
131
|
+
}
|
|
132
|
+
return encoded + "\n";
|
|
133
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Method dispatch and protocol-era negotiation.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this speaks two protocols
|
|
5
|
+
*
|
|
6
|
+
* The 2026-07-28 revision made MCP stateless: it removed the
|
|
7
|
+
* `initialize`/`notifications/initialized` handshake, moved the protocol
|
|
8
|
+
* version into every request's `_meta`, and made `server/discover` mandatory.
|
|
9
|
+
* That revision was published on 28 July 2026, and the clients in the field
|
|
10
|
+
* still open with `initialize`.
|
|
11
|
+
*
|
|
12
|
+
* The spec's own compatibility matrix has exactly one row that works against
|
|
13
|
+
* both kinds of client, and it is the dual-era server. So this one selects its
|
|
14
|
+
* behaviour from how the caller opens — modern `_meta` on the request, or an
|
|
15
|
+
* `initialize` handshake — and answers each request in the era it arrived in.
|
|
16
|
+
*
|
|
17
|
+
* `server/discover` carries a second job on stdio: it is the probe a dual-era
|
|
18
|
+
* *client* sends to work out which kind of server it is talking to. Answering it
|
|
19
|
+
* is what stops a modern client falling back to `initialize` against a server
|
|
20
|
+
* that never needed it.
|
|
21
|
+
*
|
|
22
|
+
* ## What this file does not know
|
|
23
|
+
*
|
|
24
|
+
* Anything about Zerotal. It is handed an array of {@link ArchTool} and calls
|
|
25
|
+
* them; every tool here would work unchanged behind a different transport.
|
|
26
|
+
*/
|
|
27
|
+
import { failure, success } from "./jsonrpc.ts";
|
|
28
|
+
import { LEGACY_VERSIONS, Meta, MODERN_VERSION, RpcError, SUPPORTED_VERSIONS } from "./types.ts";
|
|
29
|
+
import type {
|
|
30
|
+
ArchTool,
|
|
31
|
+
Era,
|
|
32
|
+
JsonRpcId,
|
|
33
|
+
JsonRpcRequest,
|
|
34
|
+
JsonRpcResponse,
|
|
35
|
+
ServerIdentity,
|
|
36
|
+
ToolOutcome,
|
|
37
|
+
} from "./types.ts";
|
|
38
|
+
|
|
39
|
+
/** How long a client may cache `tools/list`. The set is fixed at build time. */
|
|
40
|
+
const LIST_TTL_MS = 3_600_000;
|
|
41
|
+
|
|
42
|
+
export interface McpServerOptions {
|
|
43
|
+
identity: ServerIdentity;
|
|
44
|
+
/**
|
|
45
|
+
* The tools to expose, in the order they should be listed.
|
|
46
|
+
*
|
|
47
|
+
* Order is preserved rather than sorted: the spec asks for a deterministic
|
|
48
|
+
* order so clients can cache and prompt caches can hit, and the registry's
|
|
49
|
+
* own order is already deliberate — most useful first.
|
|
50
|
+
*/
|
|
51
|
+
tools: readonly ArchTool[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A request that is in flight, and the handle that cancels it. */
|
|
55
|
+
interface InFlight {
|
|
56
|
+
controller: AbortController;
|
|
57
|
+
cancelled: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class McpServer {
|
|
61
|
+
private readonly _identity: ServerIdentity;
|
|
62
|
+
private readonly _tools: readonly ArchTool[];
|
|
63
|
+
private readonly _byName: Map<string, ArchTool>;
|
|
64
|
+
private readonly _inFlight = new Map<JsonRpcId, InFlight>();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The era the client established, once it has.
|
|
68
|
+
*
|
|
69
|
+
* Requests carrying modern `_meta` are self-describing and never need this;
|
|
70
|
+
* it exists for the legacy era, where `initialize` sets the terms for every
|
|
71
|
+
* request that follows.
|
|
72
|
+
*/
|
|
73
|
+
private _negotiated: { era: Era; version: string } | undefined;
|
|
74
|
+
|
|
75
|
+
constructor(options: McpServerOptions) {
|
|
76
|
+
this._identity = options.identity;
|
|
77
|
+
this._tools = options.tools;
|
|
78
|
+
this._byName = new Map(options.tools.map((tool) => [tool.name, tool]));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Handle one request.
|
|
83
|
+
*
|
|
84
|
+
* Returns the reply to write, or `undefined` when there is nothing to say —
|
|
85
|
+
* a notification, or a request that was cancelled while it ran. The spec is
|
|
86
|
+
* strict about the second case: after `notifications/cancelled`, the server
|
|
87
|
+
* MUST NOT send anything further for that id.
|
|
88
|
+
*/
|
|
89
|
+
async handle(request: JsonRpcRequest): Promise<JsonRpcResponse | undefined> {
|
|
90
|
+
// Notifications carry no id and are never answered.
|
|
91
|
+
if (request.id === undefined) {
|
|
92
|
+
this._handleNotification(request);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const id = request.id;
|
|
96
|
+
|
|
97
|
+
const era = this._eraFor(request);
|
|
98
|
+
if (era === "unsupported") {
|
|
99
|
+
const requested = readProtocolVersion(request);
|
|
100
|
+
return failure(id, RpcError.UNSUPPORTED_PROTOCOL_VERSION, "Unsupported protocol version", {
|
|
101
|
+
supported: [...SUPPORTED_VERSIONS],
|
|
102
|
+
requested,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
switch (request.method) {
|
|
107
|
+
case "server/discover":
|
|
108
|
+
return success(id, this._discover());
|
|
109
|
+
case "initialize":
|
|
110
|
+
return success(id, this._initialize(request));
|
|
111
|
+
case "ping":
|
|
112
|
+
// Removed in the modern revision, but legacy clients send it as a
|
|
113
|
+
// liveness check and expect an empty result. Cheaper to answer than to
|
|
114
|
+
// explain.
|
|
115
|
+
return success(id, this._decorate({}, era));
|
|
116
|
+
case "tools/list":
|
|
117
|
+
return success(id, this._listTools(era));
|
|
118
|
+
case "tools/call":
|
|
119
|
+
return this._callTool(id, request, era);
|
|
120
|
+
default:
|
|
121
|
+
return failure(id, RpcError.METHOD_NOT_FOUND, `Unknown method: ${request.method}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Era ─────────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Which era this request is speaking, or `"unsupported"` when it names a
|
|
129
|
+
* version this server does not implement.
|
|
130
|
+
*
|
|
131
|
+
* A request that carries `_meta` protocol version is self-describing and is
|
|
132
|
+
* answered in the modern era whatever came before it — the spec treats the
|
|
133
|
+
* version as a per-request property, not a connection-wide one. Everything
|
|
134
|
+
* else falls back to what `initialize` negotiated, and then to legacy: a
|
|
135
|
+
* client that sends `tools/list` cold is not a modern one, because a modern
|
|
136
|
+
* client always states its version.
|
|
137
|
+
*/
|
|
138
|
+
private _eraFor(request: JsonRpcRequest): Era | "unsupported" {
|
|
139
|
+
// `initialize` and `server/discover` carry their own version negotiation and
|
|
140
|
+
// must never be rejected before it happens — that would leave a client with
|
|
141
|
+
// no way to find out what this server speaks.
|
|
142
|
+
if (request.method === "initialize" || request.method === "server/discover") {
|
|
143
|
+
return request.method === "initialize" ? "legacy" : "modern";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const declared = readProtocolVersion(request);
|
|
147
|
+
if (declared !== undefined) {
|
|
148
|
+
if (!(SUPPORTED_VERSIONS as readonly string[]).includes(declared)) return "unsupported";
|
|
149
|
+
return declared === MODERN_VERSION ? "modern" : "legacy";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return this._negotiated?.era ?? "legacy";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Methods ─────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `server/discover` — what this server is and what it speaks.
|
|
159
|
+
*
|
|
160
|
+
* Mandatory in the modern revision, and doubly useful on stdio: it is also the
|
|
161
|
+
* backward-compatibility probe, so answering it correctly is what tells a
|
|
162
|
+
* dual-era client it need not fall back.
|
|
163
|
+
*/
|
|
164
|
+
private _discover(): Record<string, unknown> {
|
|
165
|
+
return {
|
|
166
|
+
resultType: "complete",
|
|
167
|
+
supportedVersions: [...SUPPORTED_VERSIONS],
|
|
168
|
+
capabilities: { tools: { listChanged: false } },
|
|
169
|
+
serverInfo: { ...this._identity },
|
|
170
|
+
_meta: { [Meta.SERVER_INFO]: { ...this._identity } },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The legacy handshake. Echoes a version both ends know. */
|
|
175
|
+
private _initialize(request: JsonRpcRequest): Record<string, unknown> {
|
|
176
|
+
const requested = request.params?.["protocolVersion"];
|
|
177
|
+
const version =
|
|
178
|
+
typeof requested === "string" && (LEGACY_VERSIONS as readonly string[]).includes(requested)
|
|
179
|
+
? requested
|
|
180
|
+
: LEGACY_VERSIONS[0];
|
|
181
|
+
|
|
182
|
+
this._negotiated = { era: "legacy", version };
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
protocolVersion: version,
|
|
186
|
+
capabilities: { tools: { listChanged: false } },
|
|
187
|
+
serverInfo: { ...this._identity },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private _listTools(era: Era): Record<string, unknown> {
|
|
192
|
+
const tools = this._tools.map((tool) => ({
|
|
193
|
+
name: tool.name,
|
|
194
|
+
title: tool.title,
|
|
195
|
+
description: tool.description,
|
|
196
|
+
inputSchema: tool.inputSchema,
|
|
197
|
+
outputSchema: tool.outputSchema,
|
|
198
|
+
}));
|
|
199
|
+
|
|
200
|
+
if (era === "legacy") return { tools };
|
|
201
|
+
|
|
202
|
+
// `ttlMs` and `cacheScope` are required on a modern list result. `private`
|
|
203
|
+
// because the answer is specific to this project's checkout — nothing about
|
|
204
|
+
// it is safe for a shared intermediary to serve to someone else.
|
|
205
|
+
return this._decorate({ tools, ttlMs: LIST_TTL_MS, cacheScope: "private" }, era);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private async _callTool(
|
|
209
|
+
id: JsonRpcId,
|
|
210
|
+
request: JsonRpcRequest,
|
|
211
|
+
era: Era,
|
|
212
|
+
): Promise<JsonRpcResponse | undefined> {
|
|
213
|
+
const name = request.params?.["name"];
|
|
214
|
+
if (typeof name !== "string") {
|
|
215
|
+
return failure(id, RpcError.INVALID_PARAMS, "tools/call requires a string `name`.");
|
|
216
|
+
}
|
|
217
|
+
const tool = this._byName.get(name);
|
|
218
|
+
if (!tool) {
|
|
219
|
+
return failure(id, RpcError.INVALID_PARAMS, `Unknown tool: ${name}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const rawArgs = request.params?.["arguments"];
|
|
223
|
+
const args =
|
|
224
|
+
typeof rawArgs === "object" && rawArgs !== null && !Array.isArray(rawArgs)
|
|
225
|
+
? (rawArgs as Record<string, unknown>)
|
|
226
|
+
: {};
|
|
227
|
+
|
|
228
|
+
const entry: InFlight = { controller: new AbortController(), cancelled: false };
|
|
229
|
+
this._inFlight.set(id, entry);
|
|
230
|
+
|
|
231
|
+
let outcome: ToolOutcome;
|
|
232
|
+
try {
|
|
233
|
+
outcome = await tool.run(args, entry.controller.signal);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
// A tool that throws has still *run*. Reporting that as a protocol error
|
|
236
|
+
// would hide it from the model, which is the one party able to correct it.
|
|
237
|
+
outcome = { text: describe(error), failed: true };
|
|
238
|
+
} finally {
|
|
239
|
+
this._inFlight.delete(id);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (entry.cancelled) return undefined;
|
|
243
|
+
return success(id, this._renderOutcome(outcome, era));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A tool outcome as MCP content.
|
|
248
|
+
*
|
|
249
|
+
* The spec suggests a tool returning structured content also repeat it as
|
|
250
|
+
* serialised JSON in a text block, for clients that do not read
|
|
251
|
+
* `structuredContent`. That is skipped deliberately here: every tool in this
|
|
252
|
+
* package renders its full answer into `text` already, so the extra block
|
|
253
|
+
* would be the same information twice in a model's context window rather than
|
|
254
|
+
* a fallback for anything it would otherwise miss.
|
|
255
|
+
*/
|
|
256
|
+
private _renderOutcome(outcome: ToolOutcome, era: Era): Record<string, unknown> {
|
|
257
|
+
const result: Record<string, unknown> = {
|
|
258
|
+
content: [{ type: "text", text: outcome.text }],
|
|
259
|
+
isError: outcome.failed === true,
|
|
260
|
+
};
|
|
261
|
+
if (outcome.data !== undefined) result["structuredContent"] = outcome.data;
|
|
262
|
+
return this._decorate(result, era);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Add the fields a modern result carries and a legacy one does not. */
|
|
266
|
+
private _decorate(result: Record<string, unknown>, era: Era): Record<string, unknown> {
|
|
267
|
+
if (era === "legacy") return result;
|
|
268
|
+
return {
|
|
269
|
+
resultType: "complete",
|
|
270
|
+
...result,
|
|
271
|
+
_meta: { [Meta.SERVER_INFO]: { ...this._identity } },
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Notifications ───────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
private _handleNotification(request: JsonRpcRequest): void {
|
|
278
|
+
if (request.method === "notifications/cancelled") {
|
|
279
|
+
const target = request.params?.["requestId"];
|
|
280
|
+
if (typeof target === "string" || typeof target === "number") {
|
|
281
|
+
const entry = this._inFlight.get(target);
|
|
282
|
+
if (entry) {
|
|
283
|
+
entry.cancelled = true;
|
|
284
|
+
entry.controller.abort();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// `notifications/initialized` closes the legacy handshake and needs nothing
|
|
290
|
+
// done. Anything else is a notification this server has no interest in, and
|
|
291
|
+
// notifications are by definition not answered — including with an error.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/** The protocol version a modern request declares in `_meta`, if it declares one. */
|
|
298
|
+
function readProtocolVersion(request: JsonRpcRequest): string | undefined {
|
|
299
|
+
const meta = request.params?.["_meta"];
|
|
300
|
+
if (typeof meta !== "object" || meta === null) return undefined;
|
|
301
|
+
const version = (meta as Record<string, unknown>)[Meta.PROTOCOL_VERSION];
|
|
302
|
+
return typeof version === "string" ? version : undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function describe(error: unknown): string {
|
|
306
|
+
return error instanceof Error ? error.message : String(error);
|
|
307
|
+
}
|