omkit 0.4.8 → 0.8.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 +153 -0
- package/README.md +168 -12
- package/dist/actions/chrome-page.d.ts +41 -0
- package/dist/actions/chrome-page.js +77 -29
- package/dist/actions/drive-page.d.ts +50 -0
- package/dist/actions/drive-page.js +68 -0
- package/dist/actions/index.d.ts +5 -3
- package/dist/actions/index.js +2 -1
- package/dist/actions/prompt.d.ts +32 -5
- package/dist/actions/prompt.js +92 -8
- package/dist/cli/commands/help.js +6 -0
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/commands/ls.d.ts +11 -4
- package/dist/cli/commands/ls.js +35 -15
- package/dist/cli/commands/mcp.d.ts +10 -0
- package/dist/cli/commands/mcp.js +13 -0
- package/dist/cli/commands/skill.d.ts +17 -0
- package/dist/cli/commands/skill.js +56 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +71 -29
- package/dist/client/discover-child.d.ts +12 -0
- package/dist/client/discover-child.js +71 -0
- package/dist/client/discovery.d.ts +21 -1
- package/dist/client/discovery.js +139 -2
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.js +3 -1
- package/dist/client/outline.d.ts +35 -0
- package/dist/client/outline.js +100 -0
- package/dist/client/registry.d.ts +59 -0
- package/dist/client/runner.d.ts +12 -0
- package/dist/client/runner.js +16 -7
- package/dist/client/types.d.ts +9 -1
- package/dist/core/ActionRun.d.ts +5 -0
- package/dist/core/ActionRun.js +9 -0
- package/dist/core/ExecutionTree.d.ts +40 -2
- package/dist/core/ExecutionTree.js +66 -2
- package/dist/core/action.d.ts +2 -1
- package/dist/core/action.js +93 -9
- package/dist/core/args.d.ts +50 -0
- package/dist/core/args.js +166 -0
- package/dist/core/discovery-mode.d.ts +37 -0
- package/dist/core/discovery-mode.js +54 -0
- package/dist/core/interaction.d.ts +3 -1
- package/dist/core/om.d.ts +11 -9
- package/dist/core/om.js +182 -13
- package/dist/core/schema-hint.d.ts +20 -0
- package/dist/core/schema-hint.js +73 -0
- package/dist/core/schema-json.d.ts +12 -0
- package/dist/core/schema-json.js +12 -0
- package/dist/core/types.d.ts +113 -1
- package/dist/foundation/LogEntry.d.ts +1 -1
- package/dist/foundation/canonicalPath.d.ts +26 -0
- package/dist/foundation/canonicalPath.js +34 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/action-host.d.ts +1 -0
- package/dist/mcp/action-host.js +65 -0
- package/dist/mcp/action-identity.d.ts +23 -0
- package/dist/mcp/action-identity.js +35 -0
- package/dist/mcp/progress.d.ts +61 -0
- package/dist/mcp/progress.js +92 -0
- package/dist/mcp/resources.d.ts +55 -0
- package/dist/mcp/resources.js +208 -0
- package/dist/mcp/runs.d.ts +37 -0
- package/dist/mcp/runs.js +60 -0
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +38 -0
- package/dist/mcp/tail.d.ts +37 -0
- package/dist/mcp/tail.js +64 -0
- package/dist/mcp/tools.d.ts +54 -0
- package/dist/mcp/tools.js +374 -0
- package/dist/mcp/validate.d.ts +14 -0
- package/dist/mcp/validate.js +54 -0
- package/dist/mcp/verdict.d.ts +31 -0
- package/dist/mcp/verdict.js +47 -0
- package/dist/output/artifact/ArtifactStore.d.ts +24 -0
- package/dist/output/artifact/ArtifactStore.js +49 -0
- package/dist/output/folder/RunFolder.d.ts +23 -1
- package/dist/output/folder/RunFolder.js +55 -11
- package/dist/output/writers/views.d.ts +10 -0
- package/dist/skill/file.d.ts +26 -0
- package/dist/skill/file.js +58 -0
- package/dist/skill/index.d.ts +11 -0
- package/dist/skill/index.js +9 -0
- package/dist/skill/model.d.ts +55 -0
- package/dist/skill/model.js +116 -0
- package/dist/skill/render.d.ts +18 -0
- package/dist/skill/render.js +133 -0
- package/package.json +5 -2
package/dist/mcp/runs.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { newUuid, shortId } from "../foundation/ids.js";
|
|
2
|
+
/**
|
|
3
|
+
* How many log entries a live run keeps for `tail_run`. A watch-mode om can emit for
|
|
4
|
+
* hours, so the buffer is bounded and the oldest entries are dropped; `raw.jsonl` on disk
|
|
5
|
+
* remains the complete record, and a settled run is read from there instead.
|
|
6
|
+
*/
|
|
7
|
+
const BUFFER_LIMIT = 5000;
|
|
8
|
+
/**
|
|
9
|
+
* Every run this server started, live or settled.
|
|
10
|
+
*
|
|
11
|
+
* Runs are kept after settling so a client that started one can still ask what happened
|
|
12
|
+
* without knowing its folder — the dated folder only becomes knowable once the child has
|
|
13
|
+
* created it, which is why the tools key on a `runId` at all.
|
|
14
|
+
*/
|
|
15
|
+
export class RunRegistry {
|
|
16
|
+
runs = new Map();
|
|
17
|
+
/**
|
|
18
|
+
* Take ownership of a session. Subscription happens here, not at the call site, because
|
|
19
|
+
* `session.on("log")` must be attached before the first entry arrives — a tail that
|
|
20
|
+
* starts at the tool boundary has already missed the run's opening lines.
|
|
21
|
+
*/
|
|
22
|
+
start(name, folderName, session) {
|
|
23
|
+
const run = {
|
|
24
|
+
id: shortId(newUuid()),
|
|
25
|
+
name,
|
|
26
|
+
folderName,
|
|
27
|
+
session,
|
|
28
|
+
startedAt: Date.now(),
|
|
29
|
+
status: "running",
|
|
30
|
+
entries: [],
|
|
31
|
+
dropped: 0,
|
|
32
|
+
};
|
|
33
|
+
session.on("log", (entry) => {
|
|
34
|
+
run.entries.push(entry);
|
|
35
|
+
if (run.entries.length > BUFFER_LIMIT) {
|
|
36
|
+
run.entries.splice(0, run.entries.length - BUFFER_LIMIT);
|
|
37
|
+
run.dropped = run.entries[0].sequence;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
void session.result.then((verdict) => {
|
|
41
|
+
run.status = "settled";
|
|
42
|
+
run.verdict = verdict;
|
|
43
|
+
});
|
|
44
|
+
this.runs.set(run.id, run);
|
|
45
|
+
return run;
|
|
46
|
+
}
|
|
47
|
+
get(id) {
|
|
48
|
+
return this.runs.get(id);
|
|
49
|
+
}
|
|
50
|
+
all() {
|
|
51
|
+
return [...this.runs.values()];
|
|
52
|
+
}
|
|
53
|
+
/** Cancel every live run — the server is going away and its children must not outlive it. */
|
|
54
|
+
cancelAll() {
|
|
55
|
+
for (const run of this.runs.values()) {
|
|
56
|
+
if (run.status === "running")
|
|
57
|
+
run.session.cancel();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { OmkitClient } from "../client/index.ts";
|
|
3
|
+
export interface McpServerOptions {
|
|
4
|
+
/** The engine every omkit frontend goes through — the MCP server is the third one. */
|
|
5
|
+
readonly client: OmkitClient;
|
|
6
|
+
/** Project root: run folders resolve under `<root>/logs`. */
|
|
7
|
+
readonly root: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Build the server without binding a transport, so tests can connect it to an in-memory
|
|
11
|
+
* pair and a real launch can hand it stdio. Registration is assembly only: what each tool
|
|
12
|
+
* and resource does lives in its own module.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createMcpServer(opts: McpServerOptions): McpServer;
|
|
15
|
+
/**
|
|
16
|
+
* Bind the server to stdio and serve until the client disconnects. Nothing on this path
|
|
17
|
+
* may write to stdout: it *is* the JSON-RPC stream. Diagnostics go to stderr.
|
|
18
|
+
*/
|
|
19
|
+
export declare function startMcpServer(opts: McpServerOptions): Promise<void>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { registerTools } from "./tools.js";
|
|
4
|
+
import { registerResources } from "./resources.js";
|
|
5
|
+
import { RunRegistry } from "./runs.js";
|
|
6
|
+
/**
|
|
7
|
+
* Kept in step with package.json by hand. It is only the version this server reports to a
|
|
8
|
+
* client in `initialize`, so drifting is cosmetic — but bump both together.
|
|
9
|
+
*/
|
|
10
|
+
const VERSION = "0.7.0";
|
|
11
|
+
/**
|
|
12
|
+
* Build the server without binding a transport, so tests can connect it to an in-memory
|
|
13
|
+
* pair and a real launch can hand it stdio. Registration is assembly only: what each tool
|
|
14
|
+
* and resource does lives in its own module.
|
|
15
|
+
*/
|
|
16
|
+
export function createMcpServer(opts) {
|
|
17
|
+
const server = new McpServer({ name: "omkit", version: VERSION }, { capabilities: { tools: {}, resources: {} } });
|
|
18
|
+
const runs = new RunRegistry();
|
|
19
|
+
registerTools(server, { client: opts.client, root: opts.root, runs });
|
|
20
|
+
registerResources(server, { root: opts.root });
|
|
21
|
+
// The client hung up. Its runs are children of this process and must not outlive it.
|
|
22
|
+
// Chain rather than assign: `onclose` is a single slot on the SDK's Protocol base, so a
|
|
23
|
+
// bare assignment would silently drop whatever the SDK (or a future us) put there.
|
|
24
|
+
const previousOnClose = server.server.onclose;
|
|
25
|
+
server.server.onclose = () => {
|
|
26
|
+
runs.cancelAll();
|
|
27
|
+
previousOnClose?.();
|
|
28
|
+
};
|
|
29
|
+
return server;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Bind the server to stdio and serve until the client disconnects. Nothing on this path
|
|
33
|
+
* may write to stdout: it *is* the JSON-RPC stream. Diagnostics go to stderr.
|
|
34
|
+
*/
|
|
35
|
+
export async function startMcpServer(opts) {
|
|
36
|
+
const server = createMcpServer(opts);
|
|
37
|
+
await server.connect(new StdioServerTransport());
|
|
38
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { LogEntry } from "../foundation/LogEntry.ts";
|
|
2
|
+
export interface TailPage {
|
|
3
|
+
readonly entries: LogEntry[];
|
|
4
|
+
/** Pass this back as `cursor` to continue where this page stopped. */
|
|
5
|
+
readonly nextCursor: number;
|
|
6
|
+
/** The oldest sequence still available, when the live buffer has dropped older lines. */
|
|
7
|
+
readonly dropped: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A page from a live run's buffer. `sequence` is the run-global monotonic key the log
|
|
11
|
+
* store stamps, so it doubles as the cursor with nothing extra to track.
|
|
12
|
+
*/
|
|
13
|
+
export declare function tailLive(run: {
|
|
14
|
+
entries: readonly LogEntry[];
|
|
15
|
+
dropped: number;
|
|
16
|
+
}, cursor: number, limit: number): TailPage;
|
|
17
|
+
/**
|
|
18
|
+
* A page from a settled run's `raw.jsonl` — the complete record, which the bounded live
|
|
19
|
+
* buffer is not. Read whole and filtered: run logs are bounded by the run, and a streaming
|
|
20
|
+
* reader would buy little for a lot of machinery.
|
|
21
|
+
*
|
|
22
|
+
* An unparseable line is skipped rather than fatal: `raw.jsonl` is appended to during the
|
|
23
|
+
* run, so a tail can catch a line mid-write.
|
|
24
|
+
*/
|
|
25
|
+
export declare function tailFile(folder: string, cursor: number, limit: number): TailPage;
|
|
26
|
+
/**
|
|
27
|
+
* Trim an entry to what a client can act on. `nodeId` is an internal attribution key and
|
|
28
|
+
* says nothing a reader can use — `path` already names the node readably.
|
|
29
|
+
*/
|
|
30
|
+
export declare function publicEntry(e: LogEntry): {
|
|
31
|
+
sequence: number;
|
|
32
|
+
ts: number;
|
|
33
|
+
path: string;
|
|
34
|
+
level: string;
|
|
35
|
+
source: string;
|
|
36
|
+
message: string;
|
|
37
|
+
};
|
package/dist/mcp/tail.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const page = (entries, cursor, dropped) => ({
|
|
4
|
+
entries,
|
|
5
|
+
nextCursor: entries.length ? entries[entries.length - 1].sequence : cursor,
|
|
6
|
+
dropped,
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* A page from a live run's buffer. `sequence` is the run-global monotonic key the log
|
|
10
|
+
* store stamps, so it doubles as the cursor with nothing extra to track.
|
|
11
|
+
*/
|
|
12
|
+
export function tailLive(run, cursor, limit) {
|
|
13
|
+
const after = run.entries.filter((e) => e.sequence > cursor).slice(0, limit);
|
|
14
|
+
return page(after, cursor, run.dropped);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A page from a settled run's `raw.jsonl` — the complete record, which the bounded live
|
|
18
|
+
* buffer is not. Read whole and filtered: run logs are bounded by the run, and a streaming
|
|
19
|
+
* reader would buy little for a lot of machinery.
|
|
20
|
+
*
|
|
21
|
+
* An unparseable line is skipped rather than fatal: `raw.jsonl` is appended to during the
|
|
22
|
+
* run, so a tail can catch a line mid-write.
|
|
23
|
+
*/
|
|
24
|
+
export function tailFile(folder, cursor, limit) {
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = fs.readFileSync(path.join(folder, "raw.jsonl"), "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { entries: [], nextCursor: cursor, dropped: 0 };
|
|
31
|
+
}
|
|
32
|
+
const entries = [];
|
|
33
|
+
for (const line of raw.split("\n")) {
|
|
34
|
+
if (!line.trim())
|
|
35
|
+
continue;
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(line);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (typeof parsed.sequence !== "number" || parsed.sequence <= cursor)
|
|
44
|
+
continue;
|
|
45
|
+
entries.push(parsed);
|
|
46
|
+
if (entries.length >= limit)
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
return page(entries, cursor, 0);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Trim an entry to what a client can act on. `nodeId` is an internal attribution key and
|
|
53
|
+
* says nothing a reader can use — `path` already names the node readably.
|
|
54
|
+
*/
|
|
55
|
+
export function publicEntry(e) {
|
|
56
|
+
return {
|
|
57
|
+
sequence: e.sequence,
|
|
58
|
+
ts: e.ts,
|
|
59
|
+
path: e.path,
|
|
60
|
+
level: e.level,
|
|
61
|
+
source: e.source,
|
|
62
|
+
message: e.message,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { RunRegistry } from "./runs.ts";
|
|
3
|
+
import type { OmkitClient } from "../client/index.ts";
|
|
4
|
+
import type { RegistrationSet } from "../client/registry.ts";
|
|
5
|
+
/** Everything the tools need. Passed in so the server stays assembly and these stay logic. */
|
|
6
|
+
export interface ToolContext {
|
|
7
|
+
readonly client: OmkitClient;
|
|
8
|
+
/** Project root — run folders resolve under `<root>/logs`. */
|
|
9
|
+
readonly root: string;
|
|
10
|
+
/** Every run this server started, live or settled. */
|
|
11
|
+
readonly runs: RunRegistry;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* One entry in `list_oms`: an om or an exposed action, flattened into a single list so a
|
|
15
|
+
* client has one place to look for "what can I call". `kind` says which it is, because an
|
|
16
|
+
* action's caveats differ — see the action-host notes in the spec.
|
|
17
|
+
*/
|
|
18
|
+
export interface ToolEntry {
|
|
19
|
+
readonly kind: "om" | "action";
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly mode: "settling" | "long-lived";
|
|
22
|
+
readonly file: string;
|
|
23
|
+
readonly summary?: string;
|
|
24
|
+
/** `<name>-<hash8>` — where this entry's runs land under `logs/`. Oms only. */
|
|
25
|
+
readonly folderName?: string;
|
|
26
|
+
/** The exported binding an action-backed tool is invoked through. Actions only. */
|
|
27
|
+
readonly exportName?: string;
|
|
28
|
+
readonly inputSchema?: Record<string, unknown>;
|
|
29
|
+
/** Set when the entry cannot be called, with the reason. */
|
|
30
|
+
readonly unavailable?: string;
|
|
31
|
+
}
|
|
32
|
+
/** Flatten a registration set into the entries a client may call, dropping the rest. */
|
|
33
|
+
export declare function toEntries(set: RegistrationSet): ToolEntry[];
|
|
34
|
+
/**
|
|
35
|
+
* Every tool result carries a text block as well as `structuredContent`: a client that
|
|
36
|
+
* ignores structured output still sees the answer. Return types are inferred so they stay
|
|
37
|
+
* assignable to the SDK's `CallToolResult` union — annotating them as tuples does not.
|
|
38
|
+
*/
|
|
39
|
+
export declare function both<T>(structuredContent: T): {
|
|
40
|
+
content: {
|
|
41
|
+
type: "text";
|
|
42
|
+
text: string;
|
|
43
|
+
}[];
|
|
44
|
+
structuredContent: T;
|
|
45
|
+
};
|
|
46
|
+
/** A tool failure, in MCP's shape: a message the model can act on, not a thrown stack. */
|
|
47
|
+
export declare function toolError(message: string): {
|
|
48
|
+
content: {
|
|
49
|
+
type: "text";
|
|
50
|
+
text: string;
|
|
51
|
+
}[];
|
|
52
|
+
isError: true;
|
|
53
|
+
};
|
|
54
|
+
export declare function registerTools(server: McpServer, ctx: ToolContext): void;
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { checkArgs } from "./validate.js";
|
|
4
|
+
import { readVerdict, readVerdictAt } from "./verdict.js";
|
|
5
|
+
import { resolveRunFolder, runFileLinks } from "./resources.js";
|
|
6
|
+
import { hostFile, hostFolderName } from "./action-identity.js";
|
|
7
|
+
import { publicEntry, tailFile, tailLive } from "./tail.js";
|
|
8
|
+
import { attachElicitation, attachProgress } from "./progress.js";
|
|
9
|
+
/**
|
|
10
|
+
* How long a "settling" run gets before the server gives up on it. Generous: a real smoke
|
|
11
|
+
* suite can take minutes, and a backstop that fires early is worse than none.
|
|
12
|
+
*
|
|
13
|
+
* This is a server-side safety net, not a promise the call will be awaited — MCP client
|
|
14
|
+
* timeouts are client-side and typically far shorter. Anything routinely over about a
|
|
15
|
+
* minute belongs in `start_om`.
|
|
16
|
+
*/
|
|
17
|
+
const SETTLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
18
|
+
/** How long teardown gets to finish writing the run folder after a backstop cancel. */
|
|
19
|
+
const TEARDOWN_GRACE_MS = 10_000;
|
|
20
|
+
/**
|
|
21
|
+
* Wait for a run to settle, or cancel it and report that it did not.
|
|
22
|
+
*
|
|
23
|
+
* `mode` is the author's claim, and a wrong claim would otherwise hang the tool call until
|
|
24
|
+
* the *client* times out — which surfaces as a transport error with no run folder and
|
|
25
|
+
* nothing to read. Cancelling instead turns the mistake into a message that names it, and
|
|
26
|
+
* the run still writes its folder on the way down, so there is something to inspect.
|
|
27
|
+
*/
|
|
28
|
+
async function settleWithin(session, ms) {
|
|
29
|
+
let timer;
|
|
30
|
+
const expired = new Promise((resolve) => {
|
|
31
|
+
timer = setTimeout(() => resolve(undefined), ms);
|
|
32
|
+
});
|
|
33
|
+
try {
|
|
34
|
+
const winner = await Promise.race([session.result, expired]);
|
|
35
|
+
if (winner)
|
|
36
|
+
return winner;
|
|
37
|
+
session.cancel();
|
|
38
|
+
// Give teardown a chance to finish writing before reporting; the run is already lost
|
|
39
|
+
// either way, so a slow teardown must not extend the wait indefinitely.
|
|
40
|
+
await Promise.race([
|
|
41
|
+
session.result,
|
|
42
|
+
new Promise((resolve) => setTimeout(resolve, TEARDOWN_GRACE_MS)),
|
|
43
|
+
]);
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const entryShape = {
|
|
51
|
+
kind: z.enum(["om", "action"]),
|
|
52
|
+
name: z.string(),
|
|
53
|
+
mode: z.enum(["settling", "long-lived"]),
|
|
54
|
+
file: z.string(),
|
|
55
|
+
summary: z.string().optional(),
|
|
56
|
+
folderName: z.string().optional(),
|
|
57
|
+
exportName: z.string().optional(),
|
|
58
|
+
inputSchema: z.record(z.string(), z.unknown()).optional(),
|
|
59
|
+
unavailable: z.string().optional(),
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* The process-lifetime registry cache. Discovery forks a child and imports user code, so
|
|
63
|
+
* it is far too expensive to repeat per tool call; a client that has changed a file asks
|
|
64
|
+
* for `refresh: true`. (Watching for changes and emitting `tools/list_changed` is
|
|
65
|
+
* deferred — the tool list itself never changes, only what `list_oms` returns.)
|
|
66
|
+
*/
|
|
67
|
+
function createRegistryCache(client) {
|
|
68
|
+
let cached;
|
|
69
|
+
return (refresh = false) => {
|
|
70
|
+
if (refresh || !cached)
|
|
71
|
+
cached = client.discoverRegistrations();
|
|
72
|
+
return cached;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Flatten a registration set into the entries a client may call, dropping the rest. */
|
|
76
|
+
export function toEntries(set) {
|
|
77
|
+
const entries = [];
|
|
78
|
+
for (const om of set.oms) {
|
|
79
|
+
if (!om.mcp)
|
|
80
|
+
continue; // exposure is opt-in
|
|
81
|
+
entries.push({
|
|
82
|
+
kind: "om",
|
|
83
|
+
name: om.name,
|
|
84
|
+
mode: om.mcp.mode,
|
|
85
|
+
file: om.file,
|
|
86
|
+
folderName: om.folderName,
|
|
87
|
+
...(om.summary ? { summary: om.summary } : {}),
|
|
88
|
+
...(om.inputSchema ? { inputSchema: om.inputSchema } : {}),
|
|
89
|
+
...(om.unavailable ? { unavailable: om.unavailable } : {}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
for (const a of set.actions) {
|
|
93
|
+
if (!a.mcp)
|
|
94
|
+
continue;
|
|
95
|
+
entries.push({
|
|
96
|
+
kind: "action",
|
|
97
|
+
name: a.name,
|
|
98
|
+
mode: a.mcp.mode,
|
|
99
|
+
file: a.file,
|
|
100
|
+
exportName: a.exportName,
|
|
101
|
+
...(a.summary ? { summary: a.summary } : {}),
|
|
102
|
+
...(a.inputSchema ? { inputSchema: a.inputSchema } : {}),
|
|
103
|
+
...(a.unavailable ? { unavailable: a.unavailable } : {}),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return entries;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Every tool result carries a text block as well as `structuredContent`: a client that
|
|
110
|
+
* ignores structured output still sees the answer. Return types are inferred so they stay
|
|
111
|
+
* assignable to the SDK's `CallToolResult` union — annotating them as tuples does not.
|
|
112
|
+
*/
|
|
113
|
+
export function both(structuredContent) {
|
|
114
|
+
return {
|
|
115
|
+
content: [{ type: "text", text: JSON.stringify(structuredContent, null, 2) }],
|
|
116
|
+
structuredContent,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** A tool failure, in MCP's shape: a message the model can act on, not a thrown stack. */
|
|
120
|
+
export function toolError(message) {
|
|
121
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
122
|
+
}
|
|
123
|
+
export function registerTools(server, ctx) {
|
|
124
|
+
const registry = createRegistryCache(ctx.client);
|
|
125
|
+
server.registerTool("list_oms", {
|
|
126
|
+
title: "List runnable omkit oms and actions",
|
|
127
|
+
description: "Lists everything this project exposes over MCP: its name, what it does, whether " +
|
|
128
|
+
"it settles on its own, and the JSON Schema for its arguments. Call this before " +
|
|
129
|
+
"run_om or start_om — their `args` are not described by their own input schemas.",
|
|
130
|
+
inputSchema: {
|
|
131
|
+
refresh: z
|
|
132
|
+
.boolean()
|
|
133
|
+
.optional()
|
|
134
|
+
.describe("Re-import the project's files. Use after editing an om or action."),
|
|
135
|
+
},
|
|
136
|
+
outputSchema: {
|
|
137
|
+
entries: z.array(z.object(entryShape)),
|
|
138
|
+
warnings: z.array(z.string()),
|
|
139
|
+
},
|
|
140
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
141
|
+
}, async ({ refresh }) => {
|
|
142
|
+
const set = await registry(refresh);
|
|
143
|
+
return both({ entries: toEntries(set), warnings: set.warnings });
|
|
144
|
+
});
|
|
145
|
+
/** Find an exposed entry by name. Oms win a collision — they are the primary surface. */
|
|
146
|
+
const findEntry = async (name) => {
|
|
147
|
+
const entries = toEntries(await registry());
|
|
148
|
+
return (entries.find((e) => e.kind === "om" && e.name === name) ??
|
|
149
|
+
entries.find((e) => e.name === name));
|
|
150
|
+
};
|
|
151
|
+
const notRunnable = (name, entry) => {
|
|
152
|
+
if (!entry)
|
|
153
|
+
return `no om or action named "${name}" is exposed. Call list_oms.`;
|
|
154
|
+
if (entry.unavailable)
|
|
155
|
+
return `"${name}" is unavailable: ${entry.unavailable}`;
|
|
156
|
+
return undefined;
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Launch an entry as a supervised run.
|
|
160
|
+
*
|
|
161
|
+
* cwd is the project **root**, not the om file's directory. `omkit run` uses the file's
|
|
162
|
+
* directory, but resources here resolve under `<root>/logs` — a run launched elsewhere
|
|
163
|
+
* writes its folder somewhere this same server cannot serve, so `get_run`'s resource
|
|
164
|
+
* links would point at nothing.
|
|
165
|
+
*/
|
|
166
|
+
const launch = (entry, args) => {
|
|
167
|
+
// An action cannot run standalone, so an action-backed tool forks omkit's shipped host
|
|
168
|
+
// om instead of the action's own file — which defines no om at all.
|
|
169
|
+
const isAction = entry.kind === "action";
|
|
170
|
+
const file = isAction ? hostFile() : entry.file;
|
|
171
|
+
const env = isAction
|
|
172
|
+
? {
|
|
173
|
+
OMKIT_ACTION_FILE: entry.file,
|
|
174
|
+
OMKIT_ACTION_NAME: entry.name,
|
|
175
|
+
OMKIT_ACTION_EXPORT: entry.exportName,
|
|
176
|
+
OMKIT_ACTION_ARGS: JSON.stringify(args),
|
|
177
|
+
}
|
|
178
|
+
: { OMKIT_ARGS: JSON.stringify(args) };
|
|
179
|
+
const folderName = isAction
|
|
180
|
+
? hostFolderName(entry.name, entry.file)
|
|
181
|
+
: (entry.folderName ?? entry.name);
|
|
182
|
+
return ctx.runs.start(entry.name, folderName, ctx.client.run(file, { cwd: ctx.root, env }));
|
|
183
|
+
};
|
|
184
|
+
server.registerTool("run_om", {
|
|
185
|
+
title: "Run an om and wait for its verdict",
|
|
186
|
+
description: "Runs a settling om (or an exposed action) to completion and returns whether it " +
|
|
187
|
+
"passed, where its run folder is, its assert tally, and any failures. Call " +
|
|
188
|
+
"list_oms first to learn what `args` to pass. A long-lived entry is refused — use " +
|
|
189
|
+
"start_om for those, and for anything that takes more than about a minute.",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
name: z.string().describe("The om or action name, as reported by list_oms."),
|
|
192
|
+
args: z
|
|
193
|
+
.record(z.string(), z.unknown())
|
|
194
|
+
.optional()
|
|
195
|
+
.describe("Arguments matching the entry's inputSchema from list_oms."),
|
|
196
|
+
settleTimeoutMs: z
|
|
197
|
+
.number()
|
|
198
|
+
.int()
|
|
199
|
+
.positive()
|
|
200
|
+
.optional()
|
|
201
|
+
.describe("How long to wait for the run to settle before giving up. Default 10 minutes."),
|
|
202
|
+
},
|
|
203
|
+
outputSchema: {
|
|
204
|
+
ok: z.boolean(),
|
|
205
|
+
folder: z.string(),
|
|
206
|
+
assertions: z.object({ passed: z.number(), failed: z.number() }),
|
|
207
|
+
failures: z.array(z.object({ action: z.string(), error: z.string() })),
|
|
208
|
+
summary: z.array(z.string()),
|
|
209
|
+
},
|
|
210
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
211
|
+
}, async ({ name, args, settleTimeoutMs }, extra) => {
|
|
212
|
+
const entry = await findEntry(name);
|
|
213
|
+
const refusal = notRunnable(name, entry);
|
|
214
|
+
if (refusal || !entry)
|
|
215
|
+
return toolError(refusal);
|
|
216
|
+
if (entry.mode === "long-lived") {
|
|
217
|
+
return toolError(`"${name}" is long-lived — start it with start_om, not run_om.`);
|
|
218
|
+
}
|
|
219
|
+
const problems = checkArgs(entry.inputSchema, args ?? {});
|
|
220
|
+
if (problems.length)
|
|
221
|
+
return toolError(problems.join("\n"));
|
|
222
|
+
const run = launch(entry, args ?? {});
|
|
223
|
+
// This request lives for the whole run, so it can carry progress and it owns the
|
|
224
|
+
// run's cancellation: `extra.signal` aborting means the client withdrew the call.
|
|
225
|
+
attachProgress(run.session, extra);
|
|
226
|
+
attachElicitation(server, run.session);
|
|
227
|
+
const timeout = settleTimeoutMs ?? SETTLE_TIMEOUT_MS;
|
|
228
|
+
const settled = await settleWithin(run.session, timeout);
|
|
229
|
+
if (!settled) {
|
|
230
|
+
return toolError(`"${name}" is declared settling but did not settle within ${timeout}ms, so it ` +
|
|
231
|
+
`was cancelled. If it is meant to stay up, declare ` +
|
|
232
|
+
`.mcp({ mode: "long-lived" }) and start it with start_om.`);
|
|
233
|
+
}
|
|
234
|
+
return both(await readVerdict(settled));
|
|
235
|
+
});
|
|
236
|
+
server.registerTool("start_om", {
|
|
237
|
+
title: "Start an om without waiting for it",
|
|
238
|
+
description: "Starts an om or exposed action and returns a handle straight away. Use this for a " +
|
|
239
|
+
"long-lived entry (a server, a watcher), or for a long suite you want to check on " +
|
|
240
|
+
"later. Follow up with get_run, tail_run and cancel_run.",
|
|
241
|
+
inputSchema: {
|
|
242
|
+
name: z.string().describe("The om or action name, as reported by list_oms."),
|
|
243
|
+
args: z.record(z.string(), z.unknown()).optional(),
|
|
244
|
+
},
|
|
245
|
+
outputSchema: {
|
|
246
|
+
runId: z.string(),
|
|
247
|
+
name: z.string(),
|
|
248
|
+
// The dated run folder does not exist yet — this is the folder *family*.
|
|
249
|
+
folderName: z.string(),
|
|
250
|
+
mode: z.enum(["settling", "long-lived"]),
|
|
251
|
+
},
|
|
252
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
253
|
+
}, async ({ name, args }) => {
|
|
254
|
+
const entry = await findEntry(name);
|
|
255
|
+
const refusal = notRunnable(name, entry);
|
|
256
|
+
if (refusal || !entry)
|
|
257
|
+
return toolError(refusal);
|
|
258
|
+
const problems = checkArgs(entry.inputSchema, args ?? {});
|
|
259
|
+
if (problems.length)
|
|
260
|
+
return toolError(problems.join("\n"));
|
|
261
|
+
const run = launch(entry, args ?? {});
|
|
262
|
+
// Elicitation only — deliberately no `attachProgress`. This request ends as soon as
|
|
263
|
+
// the handle is returned, so its `extra.signal` aborts immediately, and wiring that
|
|
264
|
+
// to `session.cancel()` would kill the run this tool exists to keep alive. A client
|
|
265
|
+
// that wants progress from a started run polls `tail_run`.
|
|
266
|
+
attachElicitation(server, run.session);
|
|
267
|
+
return both({
|
|
268
|
+
runId: run.id,
|
|
269
|
+
name: entry.name,
|
|
270
|
+
folderName: run.folderName,
|
|
271
|
+
mode: entry.mode,
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
server.registerTool("get_run", {
|
|
275
|
+
title: "Check on a run",
|
|
276
|
+
description: "Reports whether a run is still going and, once it has settled, its verdict and " +
|
|
277
|
+
"links to the files it produced. `run` is a runId from start_om, or a run folder " +
|
|
278
|
+
"path under logs/.",
|
|
279
|
+
inputSchema: { run: z.string() },
|
|
280
|
+
outputSchema: {
|
|
281
|
+
status: z.enum(["running", "settled"]),
|
|
282
|
+
name: z.string(),
|
|
283
|
+
folder: z.string(),
|
|
284
|
+
ok: z.boolean().optional(),
|
|
285
|
+
assertions: z.object({ passed: z.number(), failed: z.number() }).optional(),
|
|
286
|
+
failures: z.array(z.object({ action: z.string(), error: z.string() })).optional(),
|
|
287
|
+
summary: z.array(z.string()).optional(),
|
|
288
|
+
},
|
|
289
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
290
|
+
}, async ({ run }) => {
|
|
291
|
+
const live = ctx.runs.get(run);
|
|
292
|
+
if (live?.status === "running") {
|
|
293
|
+
// The dated folder is stamped inside the child on first access, so there is
|
|
294
|
+
// genuinely nothing to report here yet — not an empty string standing in for one.
|
|
295
|
+
return both({ status: "running", name: live.name, folder: "" });
|
|
296
|
+
}
|
|
297
|
+
// A run this server started, or a folder already on disk — confined either way.
|
|
298
|
+
const folder = live?.verdict?.folder ?? resolveRunFolder(ctx.root, run);
|
|
299
|
+
if (!folder) {
|
|
300
|
+
return toolError(`no run "${run}" — pass a runId from start_om, or a folder under logs/.`);
|
|
301
|
+
}
|
|
302
|
+
const name = live?.name ?? path.basename(path.resolve(folder, "../.."));
|
|
303
|
+
const verdict = live?.verdict ? await readVerdict(live.verdict) : await readVerdictAt(folder);
|
|
304
|
+
// Links, not inlined content: `raw.jsonl` on a long run is megabytes, and a model
|
|
305
|
+
// should read only what it turns out to need.
|
|
306
|
+
const structuredContent = { status: "settled", name, ...verdict };
|
|
307
|
+
return {
|
|
308
|
+
content: [
|
|
309
|
+
{ type: "text", text: JSON.stringify(structuredContent, null, 2) },
|
|
310
|
+
...runFileLinks(ctx.root, folder),
|
|
311
|
+
],
|
|
312
|
+
structuredContent,
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
server.registerTool("cancel_run", {
|
|
316
|
+
title: "Cancel a running om",
|
|
317
|
+
description: "Tears a run down gracefully. It still finishes writing its run folder, so the " +
|
|
318
|
+
"logs and verdict of a cancelled run stay readable afterwards.",
|
|
319
|
+
inputSchema: { runId: z.string() },
|
|
320
|
+
outputSchema: { runId: z.string(), cancelled: z.boolean() },
|
|
321
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
322
|
+
}, async ({ runId }) => {
|
|
323
|
+
const live = ctx.runs.get(runId);
|
|
324
|
+
if (!live)
|
|
325
|
+
return toolError(`no run "${runId}".`);
|
|
326
|
+
// Already settled is not an error — cancelling twice, or cancelling something that
|
|
327
|
+
// finished on its own, is a reasonable thing for a client to do.
|
|
328
|
+
if (live.status === "settled")
|
|
329
|
+
return both({ runId, cancelled: false });
|
|
330
|
+
live.session.cancel();
|
|
331
|
+
return both({ runId, cancelled: true });
|
|
332
|
+
});
|
|
333
|
+
server.registerTool("tail_run", {
|
|
334
|
+
title: "Read a run's log from a cursor",
|
|
335
|
+
description: "Returns log lines after `cursor`, from the live run while it is going and from " +
|
|
336
|
+
"its raw.jsonl once it has settled. Pass the returned nextCursor back to continue. " +
|
|
337
|
+
"`run` is a runId from start_om, or a run folder path under logs/.",
|
|
338
|
+
inputSchema: {
|
|
339
|
+
run: z.string(),
|
|
340
|
+
cursor: z.number().int().nonnegative().optional().describe("0, or the last nextCursor."),
|
|
341
|
+
limit: z.number().int().positive().max(1000).optional().describe("Default 200."),
|
|
342
|
+
},
|
|
343
|
+
outputSchema: {
|
|
344
|
+
entries: z.array(z.object({
|
|
345
|
+
sequence: z.number(),
|
|
346
|
+
ts: z.number(),
|
|
347
|
+
path: z.string(),
|
|
348
|
+
level: z.string(),
|
|
349
|
+
source: z.string(),
|
|
350
|
+
message: z.string(),
|
|
351
|
+
})),
|
|
352
|
+
nextCursor: z.number(),
|
|
353
|
+
dropped: z.number(),
|
|
354
|
+
status: z.enum(["running", "settled"]),
|
|
355
|
+
},
|
|
356
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
357
|
+
}, async ({ run, cursor = 0, limit = 200 }) => {
|
|
358
|
+
const live = ctx.runs.get(run);
|
|
359
|
+
if (live?.status === "running") {
|
|
360
|
+
const tail = tailLive(live, cursor, limit);
|
|
361
|
+
return both({
|
|
362
|
+
...tail,
|
|
363
|
+
entries: tail.entries.map(publicEntry),
|
|
364
|
+
status: "running",
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
const folder = live?.verdict?.folder ?? resolveRunFolder(ctx.root, run);
|
|
368
|
+
if (!folder) {
|
|
369
|
+
return toolError(`no run "${run}" — pass a runId from start_om, or a folder under logs/.`);
|
|
370
|
+
}
|
|
371
|
+
const tail = tailFile(folder, cursor, limit);
|
|
372
|
+
return both({ ...tail, entries: tail.entries.map(publicEntry), status: "settled" });
|
|
373
|
+
});
|
|
374
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A shallow check of a tool call's `args` against the declared JSON Schema: required
|
|
3
|
+
* fields present, top-level primitives the right type. Deliberately not a validator.
|
|
4
|
+
*
|
|
5
|
+
* The real schema is a zod object living in the om's own process; the server holds only
|
|
6
|
+
* its JSON Schema projection. `resolveArgs` is what actually validates, fills defaults,
|
|
7
|
+
* and — when something is still missing — prompts, which reaches the client as an
|
|
8
|
+
* elicitation. This check exists so an obviously-wrong call fails immediately with a
|
|
9
|
+
* message naming every problem, instead of spending a fork to find out.
|
|
10
|
+
*/
|
|
11
|
+
type JsonSchemaLike = Record<string, unknown> | undefined;
|
|
12
|
+
/** Every problem found, in schema order. Empty means "worth spawning". */
|
|
13
|
+
export declare function checkArgs(schema: JsonSchemaLike, args: unknown): string[];
|
|
14
|
+
export {};
|