@cloudhal/mcp-server 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/README.md +18 -2
- package/dist/src/analytics-reports.d.ts +164 -0
- package/dist/src/analytics-reports.js +632 -0
- package/dist/src/analytics-tools.d.ts +4 -0
- package/dist/src/analytics-tools.js +597 -0
- package/dist/src/asset-tools.d.ts +5 -0
- package/dist/src/asset-tools.js +685 -0
- package/dist/src/body-tools.d.ts +46 -0
- package/dist/src/body-tools.js +708 -0
- package/dist/src/changes.d.ts +10 -0
- package/dist/src/changes.js +112 -0
- package/dist/src/chrome-tools.d.ts +8 -0
- package/dist/src/chrome-tools.js +207 -0
- package/dist/src/comment-tools.d.ts +9 -0
- package/dist/src/comment-tools.js +743 -0
- package/dist/src/comments.d.ts +184 -0
- package/dist/src/comments.js +408 -0
- package/dist/src/common.d.ts +258 -0
- package/dist/src/common.js +488 -0
- package/dist/src/content-shared.d.ts +50 -0
- package/dist/src/content-shared.js +102 -0
- package/dist/src/design-tools.d.ts +9 -0
- package/dist/src/design-tools.js +349 -0
- package/dist/src/elevation-tools.d.ts +3 -0
- package/dist/src/elevation-tools.js +331 -0
- package/dist/src/entry-terms.d.ts +14 -0
- package/dist/src/entry-terms.js +51 -0
- package/dist/src/entry-tools.d.ts +4 -0
- package/dist/src/entry-tools.js +442 -0
- package/dist/src/http.d.ts +48 -0
- package/dist/src/http.js +101 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/menu-tools.d.ts +41 -0
- package/dist/src/menu-tools.js +1058 -0
- package/dist/src/page-tools.d.ts +5 -0
- package/dist/src/page-tools.js +311 -0
- package/dist/src/preview-doc.d.ts +9 -0
- package/dist/src/preview-doc.js +56 -0
- package/dist/src/preview-view.d.ts +19 -0
- package/dist/src/preview-view.js +71 -0
- package/dist/src/publish-tools.d.ts +10 -0
- package/dist/src/publish-tools.js +248 -0
- package/dist/src/render/cdp.d.ts +29 -0
- package/dist/src/render/cdp.js +190 -0
- package/dist/src/render/checks/components.d.ts +5 -0
- package/dist/src/render/checks/components.js +95 -0
- package/dist/src/render/checks/content.d.ts +5 -0
- package/dist/src/render/checks/content.js +63 -0
- package/dist/src/render/checks/contrast.d.ts +5 -0
- package/dist/src/render/checks/contrast.js +72 -0
- package/dist/src/render/checks/index.d.ts +1 -0
- package/dist/src/render/checks/index.js +29 -0
- package/dist/src/render/checks/layout.d.ts +5 -0
- package/dist/src/render/checks/layout.js +44 -0
- package/dist/src/render/checks/motion.d.ts +5 -0
- package/dist/src/render/checks/motion.js +66 -0
- package/dist/src/render/checks/navigation.d.ts +5 -0
- package/dist/src/render/checks/navigation.js +78 -0
- package/dist/src/render/checks/prelude.d.ts +1 -0
- package/dist/src/render/checks/prelude.js +64 -0
- package/dist/src/render/limiter.d.ts +11 -0
- package/dist/src/render/limiter.js +29 -0
- package/dist/src/render/page.d.ts +19 -0
- package/dist/src/render/page.js +135 -0
- package/dist/src/render/types.d.ts +89 -0
- package/dist/src/render/types.js +140 -0
- package/dist/src/render-tools.d.ts +14 -0
- package/dist/src/render-tools.js +314 -0
- package/dist/src/render.d.ts +14 -0
- package/dist/src/render.js +33 -0
- package/dist/src/schedule-tools.d.ts +2 -0
- package/dist/src/schedule-tools.js +1596 -0
- package/dist/src/schedules.d.ts +292 -0
- package/dist/src/schedules.js +890 -0
- package/dist/src/schema-transforms.d.ts +14 -0
- package/dist/src/schema-transforms.js +50 -0
- package/dist/src/search-tools.d.ts +2 -0
- package/dist/src/search-tools.js +112 -0
- package/dist/src/server-approvals.d.ts +51 -0
- package/dist/src/server-approvals.js +273 -0
- package/dist/src/server-call.d.ts +28 -0
- package/dist/src/server-call.js +259 -0
- package/dist/src/server-instructions.d.ts +4 -0
- package/dist/src/server-instructions.js +74 -0
- package/dist/src/server-resources.d.ts +4 -0
- package/dist/src/server-resources.js +83 -0
- package/dist/src/server-schema.d.ts +20 -0
- package/dist/src/server-schema.js +82 -0
- package/dist/src/server.d.ts +80 -0
- package/dist/src/server.js +135 -0
- package/dist/src/stdio.d.ts +9 -0
- package/dist/src/stdio.js +18 -0
- package/dist/src/surface-tools.d.ts +4 -0
- package/dist/src/surface-tools.js +357 -0
- package/dist/src/taxonomy-tools.d.ts +16 -0
- package/dist/src/taxonomy-tools.js +1433 -0
- package/dist/src/terms.d.ts +47 -0
- package/dist/src/terms.js +271 -0
- package/dist/src/tools.d.ts +39 -0
- package/dist/src/tools.js +511 -0
- package/dist/src/translation-tools.d.ts +3 -0
- package/dist/src/translation-tools.js +422 -0
- package/package.json +86 -3
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Schema rewrites applied to every tool's input and output schema. */
|
|
2
|
+
import type { JsonSchema } from "./common.js";
|
|
3
|
+
/**
|
|
4
|
+
* Contract schemas reference `#/$defs/...` relative to their own root. Once embedded inside a tool
|
|
5
|
+
* schema those pointers must resolve from the tool schema's root instead, so nested `$defs` are
|
|
6
|
+
* hoisted there (names are shared and identical across contracts).
|
|
7
|
+
*/
|
|
8
|
+
export declare function hoistDefs(schema: JsonSchema): JsonSchema;
|
|
9
|
+
/**
|
|
10
|
+
* Inputs may name an image by asset alone (`{ assetId, alt? }`, RFC-0004 §2.2): the store writes
|
|
11
|
+
* `src`, `width` and `height`. So an input ImageRef needs `src` or `assetId`, and `alt` may be left
|
|
12
|
+
* out; stored documents keep the contract's strict shape.
|
|
13
|
+
*/
|
|
14
|
+
export declare function relaxImageRefs(schema: JsonSchema): JsonSchema;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Schema rewrites applied to every tool's input and output schema. */
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Contract schemas reference `#/$defs/...` relative to their own root. Once embedded inside a tool
|
|
7
|
+
* schema those pointers must resolve from the tool schema's root instead, so nested `$defs` are
|
|
8
|
+
* hoisted there (names are shared and identical across contracts).
|
|
9
|
+
*/
|
|
10
|
+
export function hoistDefs(schema ) {
|
|
11
|
+
const defs = {};
|
|
12
|
+
const walk = (node , isRoot ) => {
|
|
13
|
+
if (Array.isArray(node)) return node.map((n) => walk(n, false));
|
|
14
|
+
if (!node || typeof node !== "object") return node;
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const [k, v] of Object.entries(node )) {
|
|
17
|
+
if (k === "$defs" && !isRoot) {
|
|
18
|
+
for (const [name, def] of Object.entries(v ))
|
|
19
|
+
defs[name] = walk(def, false);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
out[k] = walk(v, false);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
};
|
|
26
|
+
const root = walk(schema, true) ;
|
|
27
|
+
const own = (root.$defs ) ?? {};
|
|
28
|
+
return Object.keys(defs).length || Object.keys(own).length
|
|
29
|
+
? { ...root, $defs: { ...defs, ...own } }
|
|
30
|
+
: root;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Inputs may name an image by asset alone (`{ assetId, alt? }`, RFC-0004 §2.2): the store writes
|
|
35
|
+
* `src`, `width` and `height`. So an input ImageRef needs `src` or `assetId`, and `alt` may be left
|
|
36
|
+
* out; stored documents keep the contract's strict shape.
|
|
37
|
+
*/
|
|
38
|
+
export function relaxImageRefs(schema ) {
|
|
39
|
+
const defs = schema.$defs ;
|
|
40
|
+
const ref = defs?.ImageRef;
|
|
41
|
+
if (!ref) return schema;
|
|
42
|
+
const { required: _r, ...rest } = ref;
|
|
43
|
+
return {
|
|
44
|
+
...schema,
|
|
45
|
+
$defs: {
|
|
46
|
+
...defs,
|
|
47
|
+
ImageRef: { ...rest, anyOf: [{ required: ["src"] }, { required: ["assetId"] }] },
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `search_site` (RFC-0029 §8): find a site's articles, pages, terms, media, menu items and redirects
|
|
3
|
+
* by words, drafts included, the way Studio's palette does; or, with `published`, what readers
|
|
4
|
+
* find on `/search` (the site's search provider: published, inside time gates).
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
MARK_CLOSE,
|
|
8
|
+
MARK_OPEN,
|
|
9
|
+
SEARCH_KINDS,
|
|
10
|
+
|
|
11
|
+
SITE_SEARCH_KINDS,
|
|
12
|
+
SITE_SEARCH_LIMIT_MAX,
|
|
13
|
+
|
|
14
|
+
searchSiteNodes,
|
|
15
|
+
} from "@cloudhal/core-ts";
|
|
16
|
+
import { id, mapError, obj, requireSite, str, } from "./common.js";
|
|
17
|
+
|
|
18
|
+
const HIT = obj(
|
|
19
|
+
{
|
|
20
|
+
kind: { type: "string" },
|
|
21
|
+
id: { type: "string" },
|
|
22
|
+
title: { type: "string" },
|
|
23
|
+
detail: { type: "string" },
|
|
24
|
+
context: { type: "string" },
|
|
25
|
+
status: { type: "string" },
|
|
26
|
+
locale: { type: "string" },
|
|
27
|
+
ref: { type: "string" },
|
|
28
|
+
href: { type: "string" },
|
|
29
|
+
snippet: { type: "string" },
|
|
30
|
+
},
|
|
31
|
+
["kind", "title"],
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const plain = (snippet ) => snippet.replaceAll(MARK_OPEN, "").replaceAll(MARK_CLOSE, "");
|
|
35
|
+
|
|
36
|
+
const searchSiteTool = {
|
|
37
|
+
name: "search_site",
|
|
38
|
+
title: "Search the site",
|
|
39
|
+
description:
|
|
40
|
+
"Find nodes by words instead of listing everything. By default searches what you edit, drafts included: articles (kind entry), pages, terms, media (asset), menu items (menu: `id` is the menu, `ref` the item) and redirects; every word must start a word of the title, slug, path, labels or alt text; up to `limit` hits per kind. With `published: true`, returns what readers find on /search instead (published articles, pages, terms and authors, with snippets), in the site's default locale.",
|
|
41
|
+
inputSchema: obj(
|
|
42
|
+
{
|
|
43
|
+
siteId: id,
|
|
44
|
+
query: str("Words to find", { minLength: 1, maxLength: 200 }),
|
|
45
|
+
kinds: {
|
|
46
|
+
type: "array",
|
|
47
|
+
description:
|
|
48
|
+
"Only these kinds. Editing view: entry, page, term, asset, menu, redirect. Published view: article, page, term, author.",
|
|
49
|
+
items: { type: "string", enum: [...new Set([...SITE_SEARCH_KINDS, ...SEARCH_KINDS])] },
|
|
50
|
+
minItems: 1,
|
|
51
|
+
uniqueItems: true,
|
|
52
|
+
},
|
|
53
|
+
published: { type: "boolean", description: "Search what readers see (default false)" },
|
|
54
|
+
limit: {
|
|
55
|
+
type: "integer",
|
|
56
|
+
description: "Hits per kind (editing view) or in all (published view)",
|
|
57
|
+
minimum: 1,
|
|
58
|
+
maximum: SITE_SEARCH_LIMIT_MAX,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
["siteId", "query"],
|
|
62
|
+
),
|
|
63
|
+
outputSchema: obj({ query: { type: "string" }, hits: { type: "array", items: HIT } }, [
|
|
64
|
+
"query",
|
|
65
|
+
"hits",
|
|
66
|
+
]),
|
|
67
|
+
annotations: {
|
|
68
|
+
readOnlyHint: true,
|
|
69
|
+
destructiveHint: false,
|
|
70
|
+
idempotentHint: true,
|
|
71
|
+
openWorldHint: false,
|
|
72
|
+
},
|
|
73
|
+
scopes: ["content:read"],
|
|
74
|
+
handler: async (a, ctx) => {
|
|
75
|
+
const site = await requireSite(ctx, a.siteId );
|
|
76
|
+
const query = a.query ;
|
|
77
|
+
const kinds = a.kinds ;
|
|
78
|
+
const limit = a.limit ;
|
|
79
|
+
try {
|
|
80
|
+
if (a.published === true) {
|
|
81
|
+
const r = await ctx.store.search(site.id, query, {
|
|
82
|
+
locale: site.defaultLocale,
|
|
83
|
+
now: (ctx.now?.() ?? new Date()).getTime(),
|
|
84
|
+
perPage: limit ?? SITE_SEARCH_LIMIT_MAX,
|
|
85
|
+
...(kinds ? { kinds: kinds } : {}),
|
|
86
|
+
...(site.publishedSnapshotId ? { snapshotId: site.publishedSnapshotId } : {}),
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
query: r.query,
|
|
90
|
+
hits: r.hits.map((h) => ({
|
|
91
|
+
kind: h.kind ?? "article",
|
|
92
|
+
id: h.nodeId,
|
|
93
|
+
title: h.title,
|
|
94
|
+
href: h.href,
|
|
95
|
+
...(h.context ? { context: h.context } : {}),
|
|
96
|
+
...(h.snippet ? { snippet: plain(h.snippet) } : {}),
|
|
97
|
+
})),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return await searchSiteNodes(ctx.store, site.id, query, {
|
|
101
|
+
...(kinds ? { kinds: kinds } : {}),
|
|
102
|
+
...(limit !== undefined ? { limit } : {}),
|
|
103
|
+
});
|
|
104
|
+
} catch (e) {
|
|
105
|
+
mapError(e);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export function searchTools() {
|
|
111
|
+
return [searchSiteTool];
|
|
112
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval half of a tool call: the answer a call carries (elicitation round 2, a redeemed
|
|
3
|
+
* `approvalId`, a `confirmToken`) checked against its binding, and the request when policy wants a
|
|
4
|
+
* person (Studio for agents and role limits, otherwise the client's elicitation).
|
|
5
|
+
*/
|
|
6
|
+
import { type Principal } from "@cloudhal/agent-runtime";
|
|
7
|
+
import { type CallToolResult, type InputRequiredResult, type ServerContext } from "@modelcontextprotocol/server";
|
|
8
|
+
import type { RunContext } from "./schedules.js";
|
|
9
|
+
import type { CallRuntime } from "./server-call.js";
|
|
10
|
+
import type { runTool, ToolDefinition } from "./tools.js";
|
|
11
|
+
export interface ApprovalState {
|
|
12
|
+
tool: string;
|
|
13
|
+
/** sha256 of the canonical arguments, so an approval cannot be replayed for other arguments. */
|
|
14
|
+
args: string;
|
|
15
|
+
/** The site's content digest when the person was asked; a later change voids the approval. */
|
|
16
|
+
state: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function errorResult(text: string): CallToolResult;
|
|
19
|
+
/** What an approval is tied to: the tool, the arguments and the state of what the call acts on. */
|
|
20
|
+
export interface Binding {
|
|
21
|
+
argsHash: string;
|
|
22
|
+
/** The current state, or `null` when it cannot be read (nothing may be filed or matched then). */
|
|
23
|
+
stateHash: () => Promise<string | null>;
|
|
24
|
+
matches: (state: ApprovalState | undefined) => Promise<boolean>;
|
|
25
|
+
unbound: () => CallToolResult;
|
|
26
|
+
}
|
|
27
|
+
/** One call in flight. */
|
|
28
|
+
export interface Call {
|
|
29
|
+
rt: CallRuntime;
|
|
30
|
+
def: ToolDefinition;
|
|
31
|
+
args: Record<string, unknown>;
|
|
32
|
+
ctx: ServerContext;
|
|
33
|
+
principal: Principal;
|
|
34
|
+
run: RunContext | undefined;
|
|
35
|
+
siteId: string | undefined;
|
|
36
|
+
binding: Binding;
|
|
37
|
+
}
|
|
38
|
+
/** `confirmed`: a person said yes to this call; `approved`: someone who could make it decided it. */
|
|
39
|
+
export interface Clearance {
|
|
40
|
+
confirmed: boolean;
|
|
41
|
+
approved: boolean;
|
|
42
|
+
}
|
|
43
|
+
/** The approval the call carries, checked against its binding. */
|
|
44
|
+
export declare function clearance(call: Call, approvalId: unknown, confirmToken: unknown): Promise<Clearance | CallToolResult> | Clearance;
|
|
45
|
+
export type Outcome = Awaited<ReturnType<typeof runTool>>;
|
|
46
|
+
type ApprovalDecision = Extract<Outcome["decision"], {
|
|
47
|
+
kind: "approval-required";
|
|
48
|
+
}>;
|
|
49
|
+
/** Policy wants a person: file the request in Studio, or ask in the client. */
|
|
50
|
+
export declare function approvalRequired(call: Call, outcome: Outcome, decision: ApprovalDecision): Promise<CallToolResult | InputRequiredResult> | CallToolResult;
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval half of a tool call: the answer a call carries (elicitation round 2, a redeemed
|
|
3
|
+
* `approvalId`, a `confirmToken`) checked against its binding, and the request when policy wants a
|
|
4
|
+
* person (Studio for agents and role limits, otherwise the client's elicitation).
|
|
5
|
+
*/
|
|
6
|
+
import { effectiveMode, } from "@cloudhal/agent-runtime";
|
|
7
|
+
import {
|
|
8
|
+
acceptedContent,
|
|
9
|
+
|
|
10
|
+
CLIENT_CAPABILITIES_META_KEY,
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
inputRequired,
|
|
15
|
+
inputResponse,
|
|
16
|
+
|
|
17
|
+
} from "@modelcontextprotocol/server";
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
const REDEEM_MESSAGES = {
|
|
31
|
+
"not-found": "unknown approvalId",
|
|
32
|
+
pending: "still waiting for a person to approve it in Studio — try again later",
|
|
33
|
+
denied: "a person denied this request; nothing changed",
|
|
34
|
+
consumed: "this approval was already used; ask again",
|
|
35
|
+
expired: "this approval expired; ask again",
|
|
36
|
+
mismatch: "this approval was for a different call",
|
|
37
|
+
"state-changed":
|
|
38
|
+
"the site changed after the approval was requested; ask again so the person sees the current changes",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Where an agent sees what a carried-out request did. */
|
|
42
|
+
function outcomeTools(tool ) {
|
|
43
|
+
return tool.includes("comment")
|
|
44
|
+
? "list_comments or get_comment_thread"
|
|
45
|
+
: "get_release or list_schedule";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const APPROVAL_SCHEMA = {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
confirm: {
|
|
52
|
+
type: "boolean",
|
|
53
|
+
title: "Approve",
|
|
54
|
+
description: "true to let the action run, false to stop here",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
required: ["confirm"],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function errorResult(text ) {
|
|
61
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What an approval is tied to: the tool, the arguments and the state of what the call acts on. */
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
/** One call in flight. */
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
/** `confirmed`: a person said yes to this call; `approved`: someone who could make it decided it. */
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
/** The approval the call carries, checked against its binding. */
|
|
92
|
+
export function clearance(
|
|
93
|
+
call ,
|
|
94
|
+
approvalId ,
|
|
95
|
+
confirmToken ,
|
|
96
|
+
) {
|
|
97
|
+
const answer = inputResponse(call.ctx.mcpReq.inputResponses, "approval");
|
|
98
|
+
if (answer.kind !== "missing") return elicitedClearance(call, answer);
|
|
99
|
+
if (approvalId !== undefined) return redeemedClearance(call, approvalId);
|
|
100
|
+
if (confirmToken !== undefined) return tokenClearance(call, confirmToken);
|
|
101
|
+
return { confirmed: false, approved: false };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Round 2 of an approval: the client echoed our elicitation answer + signed state. */
|
|
105
|
+
async function elicitedClearance(
|
|
106
|
+
call ,
|
|
107
|
+
answer ,
|
|
108
|
+
) {
|
|
109
|
+
const { def, args, ctx, principal } = call;
|
|
110
|
+
if (answer.kind !== "elicit") return errorResult("unexpected input response kind");
|
|
111
|
+
if (!(await call.binding.matches(ctx.mcpReq.requestState ())))
|
|
112
|
+
return errorResult(
|
|
113
|
+
"approval state does not match this call (different arguments, or the site changed meanwhile); start over",
|
|
114
|
+
);
|
|
115
|
+
const content = acceptedContent (ctx.mcpReq.inputResponses, "approval");
|
|
116
|
+
if (answer.action !== "accept" || content?.confirm !== true) {
|
|
117
|
+
await call.rt.options.audit?.record({
|
|
118
|
+
at: new Date().toISOString(),
|
|
119
|
+
actor: principal.actor,
|
|
120
|
+
siteId: call.siteId,
|
|
121
|
+
tool: def.name,
|
|
122
|
+
decision: "deny",
|
|
123
|
+
reason: answer.action === "accept" ? "approval refused" : `approval ${answer.action}d`,
|
|
124
|
+
skill: principal.skill?.name,
|
|
125
|
+
input: def.auditArgs ? def.auditArgs(args) : args,
|
|
126
|
+
outcome: "error",
|
|
127
|
+
});
|
|
128
|
+
return errorResult(`${def.name} was not approved; nothing changed.`);
|
|
129
|
+
}
|
|
130
|
+
return { confirmed: true, approved: false };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A redeemed out-of-band approval (ADR-0014): someone who could make the full call decided it. */
|
|
134
|
+
async function redeemedClearance(
|
|
135
|
+
call ,
|
|
136
|
+
approvalId ,
|
|
137
|
+
) {
|
|
138
|
+
const { def } = call;
|
|
139
|
+
const approvals = call.rt.options.approvals;
|
|
140
|
+
if (!approvals) return errorResult("approvalId is not accepted on this server");
|
|
141
|
+
if (typeof approvalId !== "string") return errorResult("approvalId must be a string");
|
|
142
|
+
// Comment and release tools are carried out by the person who approves them
|
|
143
|
+
// (RFC-0007 §10.3, RFC-0008 §10.4).
|
|
144
|
+
if (def.carryOutOnApproval) {
|
|
145
|
+
const request = await approvals.get(approvalId);
|
|
146
|
+
return errorResult(
|
|
147
|
+
request?.status === "consumed" && request.tool === def.name
|
|
148
|
+
? `approval ${approvalId}: already carried out — the person who approved it ran ${def.name} at once. Check the result with ${outcomeTools(def.name)}.`
|
|
149
|
+
: `approval ${approvalId}: ${request?.status === "denied" ? REDEEM_MESSAGES.denied : `${def.name} is carried out in Cloudhal Studio when a person approves it; there is nothing to redeem`}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const current = await call.binding.stateHash();
|
|
153
|
+
if (current === null) return call.binding.unbound();
|
|
154
|
+
const redeemed = await approvals.redeem(approvalId, {
|
|
155
|
+
tool: def.name,
|
|
156
|
+
argsHash: call.binding.argsHash,
|
|
157
|
+
actorId: call.principal.actor.id,
|
|
158
|
+
stateHash: current,
|
|
159
|
+
});
|
|
160
|
+
if (!redeemed.ok)
|
|
161
|
+
return errorResult(`approval ${approvalId}: ${REDEEM_MESSAGES[redeemed.reason]}`);
|
|
162
|
+
return { confirmed: true, approved: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The local fallback: a signed token the server handed out with the approval question. */
|
|
166
|
+
async function tokenClearance(
|
|
167
|
+
call ,
|
|
168
|
+
confirmToken ,
|
|
169
|
+
) {
|
|
170
|
+
if (!call.rt.fallback) return errorResult("confirmToken is not accepted on this server");
|
|
171
|
+
if (typeof confirmToken !== "string") return errorResult("confirmToken must be a string");
|
|
172
|
+
let state ;
|
|
173
|
+
try {
|
|
174
|
+
state = await call.rt.codec.verify(confirmToken, call.ctx);
|
|
175
|
+
} catch {
|
|
176
|
+
state = undefined;
|
|
177
|
+
}
|
|
178
|
+
if (!(await call.binding.matches(state)))
|
|
179
|
+
return errorResult("invalid, expired or mismatched confirmToken; ask for approval again");
|
|
180
|
+
return { confirmed: true, approved: false };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
/** Policy wants a person: file the request in Studio, or ask in the client. */
|
|
187
|
+
export function approvalRequired(
|
|
188
|
+
call ,
|
|
189
|
+
outcome ,
|
|
190
|
+
decision ,
|
|
191
|
+
) {
|
|
192
|
+
const approvals = call.rt.options.approvals;
|
|
193
|
+
const prompt = outcome.approvalPrompt ?? decision.reason;
|
|
194
|
+
// A call beyond the caller's role (cause `role`) is decided by someone else, never in the
|
|
195
|
+
// caller's own client (RFC-0005 §10.2).
|
|
196
|
+
const role = decision.cause === "role";
|
|
197
|
+
if (role && !approvals)
|
|
198
|
+
return errorResult(`denied: ${decision.reason}. Ask someone whose role allows it (an Editor).`);
|
|
199
|
+
// Agents with their own credentials never answer for a person: the request waits in Studio.
|
|
200
|
+
if (approvals && (call.principal.actor.kind === "agent" || role))
|
|
201
|
+
return requestInStudio(call, { approvals, outcome, decision, prompt });
|
|
202
|
+
return askInClient(call, prompt);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function requestInStudio(
|
|
206
|
+
call ,
|
|
207
|
+
input ,
|
|
208
|
+
) {
|
|
209
|
+
const { def, args, principal, run } = call;
|
|
210
|
+
const { approvals, outcome, decision, prompt } = input;
|
|
211
|
+
const current = await call.binding.stateHash();
|
|
212
|
+
if (current === null) return call.binding.unbound();
|
|
213
|
+
const pending = await approvals.request({
|
|
214
|
+
siteId: call.siteId,
|
|
215
|
+
tool: def.name,
|
|
216
|
+
argsHash: call.binding.argsHash,
|
|
217
|
+
input: args,
|
|
218
|
+
actor: principal.actor,
|
|
219
|
+
prompt,
|
|
220
|
+
stateHash: current,
|
|
221
|
+
mode: effectiveMode(principal),
|
|
222
|
+
cause: decision.cause,
|
|
223
|
+
...(outcome.autoCheck ? { autoCheck: outcome.autoCheck } : {}),
|
|
224
|
+
...(run ? { runId: run.run.id } : {}),
|
|
225
|
+
});
|
|
226
|
+
if (def.carryOutOnApproval)
|
|
227
|
+
return errorResult(
|
|
228
|
+
`approval pending: ${prompt}\nA person must approve request ${pending.id} in Cloudhal Studio (site → Activity → Approvals); approving it carries the call out at once, as long as what it acts on has not changed meanwhile. Do not call ${def.name} again for it; check the outcome later with ${outcomeTools(def.name)}. It expires ${pending.expiresAt}.\napprovalId: ${pending.id}`,
|
|
229
|
+
);
|
|
230
|
+
// A person's own request is carried out when someone approves it (RFC-0005 §6).
|
|
231
|
+
if (principal.actor.kind !== "agent")
|
|
232
|
+
return errorResult(
|
|
233
|
+
`approval pending: ${decision.reason}\nRequest ${pending.id} waits in Cloudhal Studio (site → Activity → Approvals) for someone whose role allows it; approving it runs ${def.name} for you. It expires ${pending.expiresAt}.\napprovalId: ${pending.id}`,
|
|
234
|
+
);
|
|
235
|
+
return errorResult(
|
|
236
|
+
`approval pending: ${prompt}\nA person must approve request ${pending.id} in Cloudhal Studio (site → Activity → Approvals). After it is approved, call ${def.name} again with the same arguments plus approvalId "${pending.id}". It expires ${pending.expiresAt}.\napprovalId: ${pending.id}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Ask the person in their MCP client (elicitation), or hand out a confirmToken (local fallback). */
|
|
241
|
+
async function askInClient(
|
|
242
|
+
call ,
|
|
243
|
+
prompt ,
|
|
244
|
+
) {
|
|
245
|
+
const { rt, def, ctx } = call;
|
|
246
|
+
const current = await call.binding.stateHash();
|
|
247
|
+
if (current === null) return call.binding.unbound();
|
|
248
|
+
const state = await rt.codec.mint(
|
|
249
|
+
{ tool: def.name, args: call.binding.argsHash, state: current },
|
|
250
|
+
ctx,
|
|
251
|
+
);
|
|
252
|
+
// 2026-07-28 requests carry the client's capabilities in their _meta envelope (stateless
|
|
253
|
+
// HTTP); 2025-era connections declared them at initialize.
|
|
254
|
+
const envelope = ctx.mcpReq.envelope ;
|
|
255
|
+
const caps =
|
|
256
|
+
(envelope?.[CLIENT_CAPABILITIES_META_KEY] ) ??
|
|
257
|
+
rt.server.server.getClientCapabilities();
|
|
258
|
+
if (caps?.elicitation) {
|
|
259
|
+
return inputRequired({
|
|
260
|
+
inputRequests: {
|
|
261
|
+
approval: inputRequired.elicit({ message: prompt, requestedSchema: APPROVAL_SCHEMA }),
|
|
262
|
+
},
|
|
263
|
+
requestState: state,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (!rt.fallback)
|
|
267
|
+
return errorResult(
|
|
268
|
+
`approval required: ${prompt}\nThis client cannot collect approval (no elicitation support).`,
|
|
269
|
+
);
|
|
270
|
+
return errorResult(
|
|
271
|
+
`approval required: ${prompt}\nAsk the person. If (and only if) they approve, call ${def.name} again with the same arguments plus confirmToken: "${state}" (valid ${Math.round((rt.options.approvalTtlSeconds ?? 600) / 60)} minutes).`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One tool call through the MCP layer, in order: the declared skill, the automation run, the
|
|
3
|
+
* approval answer the call carries (elicitation round 2, a redeemed `approvalId` or a
|
|
4
|
+
* `confirmToken`), `runTool`, and when policy wants a person, the approval request (Studio, or
|
|
5
|
+
* the client's elicitation).
|
|
6
|
+
*
|
|
7
|
+
* Approvals are bound to the tool, the canonical arguments and the state of what the call acts on
|
|
8
|
+
* (`Binding`); a state that cannot be computed is `null` and fails closed.
|
|
9
|
+
*/
|
|
10
|
+
import { type LoadedSkill, type PolicyEngine } from "@cloudhal/agent-runtime";
|
|
11
|
+
import type { CallToolResult, createRequestStateCodec, InputRequiredResult, McpServer, ServerContext } from "@modelcontextprotocol/server";
|
|
12
|
+
import type { CloudhalMcpOptions } from "./server.js";
|
|
13
|
+
import { type ApprovalState } from "./server-approvals.js";
|
|
14
|
+
import { type ToolDefinition } from "./tools.js";
|
|
15
|
+
export declare const SKILL_META_KEY = "cloudhal.skill";
|
|
16
|
+
export type ApprovalCodec = ReturnType<typeof createRequestStateCodec<ApprovalState>>;
|
|
17
|
+
/** What every call of one server instance shares. */
|
|
18
|
+
export interface CallRuntime {
|
|
19
|
+
options: CloudhalMcpOptions;
|
|
20
|
+
policy: PolicyEngine;
|
|
21
|
+
skills: Map<string, LoadedSkill>;
|
|
22
|
+
codec: ApprovalCodec;
|
|
23
|
+
/** Hand out `confirmToken`s to clients that cannot elicit (local only). */
|
|
24
|
+
fallback: boolean;
|
|
25
|
+
server: McpServer;
|
|
26
|
+
}
|
|
27
|
+
/** The `registerTool` callback for `def`. */
|
|
28
|
+
export declare function handleToolCall(rt: CallRuntime, def: ToolDefinition, rawArgs: unknown, ctx: ServerContext): Promise<CallToolResult | InputRequiredResult>;
|