@nimbus-dev/connectors 0.6.0 → 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/connectors/apple/src/adapters.ts +56 -1
- package/connectors/apple/src/caldav-core.ts +7 -0
- package/connectors/apple/src/calendar-tools.ts +34 -0
- package/connectors/apple/src/tools.ts +2 -1
- package/connectors/aws/src/tools.ts +110 -0
- package/connectors/azure/src/tools.ts +70 -0
- package/connectors/circleci/src/tools.ts +63 -1
- package/connectors/gcp/src/tools.ts +74 -16
- package/connectors/github/src/tools.ts +298 -6
- package/connectors/gitlab/src/gitlab-files.ts +213 -0
- package/connectors/gitlab/src/tools.ts +226 -0
- package/connectors/kubernetes/src/tools.ts +35 -15
- package/connectors/slack/src/tools.ts +56 -5
- package/connectors/snowflake/src/server.ts +69 -2
- package/connectors/teams/src/bot-send.ts +10 -2
- package/connectors/teams/src/tools.ts +18 -1
- package/package.json +1 -1
- package/shared/audit-chain.ts +5 -2
- package/shared/commit-files.ts +138 -0
- package/shared/consent-kit.ts +141 -31
- package/shared/github-files.ts +195 -0
- package/shared/github-rest.ts +12 -0
- package/shared/k8s-deployment-view.ts +68 -0
- package/shared/prepare-tool-ids.ts +21 -0
- package/shared/private-kubeconfig.ts +110 -0
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
assertCombinedSize,
|
|
4
|
+
assertCommitFiles,
|
|
5
|
+
COMMIT_MAX_COMBINED_BYTES,
|
|
6
|
+
COMMIT_MAX_FILES,
|
|
7
|
+
commitFilePathSchema,
|
|
8
|
+
commitFilesSchema,
|
|
9
|
+
fullShaSchema,
|
|
10
|
+
notOverwritable,
|
|
11
|
+
} from "../../../shared/commit-files.ts";
|
|
2
12
|
import {
|
|
3
13
|
type ConsentServer,
|
|
14
|
+
createPrepareToolRegistrar,
|
|
4
15
|
createWriteToolRegistrar,
|
|
5
16
|
type WriteToolConfig,
|
|
6
17
|
} from "../../../shared/consent-kit.ts";
|
|
@@ -21,6 +32,14 @@ import {
|
|
|
21
32
|
type RestFetchResult,
|
|
22
33
|
toRestFetchResult,
|
|
23
34
|
} from "../../../shared/rest-tool-kit.ts";
|
|
35
|
+
import {
|
|
36
|
+
assertRegularTargets,
|
|
37
|
+
branchHead,
|
|
38
|
+
type GitlabFileRead,
|
|
39
|
+
type GitlabGet,
|
|
40
|
+
readGitlabFilesAt,
|
|
41
|
+
refCommit,
|
|
42
|
+
} from "./gitlab-files.ts";
|
|
24
43
|
|
|
25
44
|
/**
|
|
26
45
|
* A project reference that reaches a URL path: a numeric id, or `/`-separated segments of
|
|
@@ -163,6 +182,9 @@ export const GITLAB_TOOL_NAMES = [
|
|
|
163
182
|
"gitlab_job_log_tail",
|
|
164
183
|
"gitlab_pipeline_retry",
|
|
165
184
|
"gitlab_pipeline_cancel",
|
|
185
|
+
"gitlab_files_get",
|
|
186
|
+
"gitlab_commit_prepare",
|
|
187
|
+
"gitlab_commit_create",
|
|
166
188
|
] as const;
|
|
167
189
|
|
|
168
190
|
export function registerGitlabTools(
|
|
@@ -182,6 +204,16 @@ export function registerGitlabTools(
|
|
|
182
204
|
scopeKinds: ["repo"],
|
|
183
205
|
});
|
|
184
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Prepare tools (shared/prepare-tool-ids.ts) read the state a write is bound to, for the gateway to
|
|
209
|
+
* call BEFORE the write's approval prompt; they are neither reads nor writes to the kit.
|
|
210
|
+
*/
|
|
211
|
+
const registerPrepareTool = createPrepareToolRegistrar(server, {
|
|
212
|
+
connector: "gitlab",
|
|
213
|
+
scopeEnv: "NIMBUS_MCP_GITLAB_WRITE_SCOPE",
|
|
214
|
+
scopeKinds: ["repo"],
|
|
215
|
+
});
|
|
216
|
+
|
|
185
217
|
/**
|
|
186
218
|
* Standard GitLab tool, read or write: `token → glFetch(buildUrl[, buildInit]) →
|
|
187
219
|
* mcpJsonResultIfOk("GitLab", res)`. `buildUrl` returns the relative path (or absolute URL) and
|
|
@@ -707,4 +739,198 @@ export function registerGitlabTools(
|
|
|
707
739
|
"Cancel a pipeline.",
|
|
708
740
|
"cancel",
|
|
709
741
|
);
|
|
742
|
+
|
|
743
|
+
// -------------------------------------------------------------------------------------------
|
|
744
|
+
// Multi-file commit: gitlab_files_get (read), gitlab_commit_prepare (prepare), gitlab_commit_create
|
|
745
|
+
// -------------------------------------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
/** Bound GETs: the token is read at call time, before the first request. */
|
|
748
|
+
const gitlabGet = (): GitlabGet => {
|
|
749
|
+
const token = requireProcessEnv("GITLAB_PAT");
|
|
750
|
+
return (path) => glFetch(token, path);
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
const READ_LIMIT = COMMIT_MAX_COMBINED_BYTES;
|
|
754
|
+
|
|
755
|
+
/** What a read tool reports for one file; `content` only for text. */
|
|
756
|
+
function fileView(f: GitlabFileRead): Record<string, unknown> {
|
|
757
|
+
if (!f.exists) return { path: f.path, exists: false };
|
|
758
|
+
const base = {
|
|
759
|
+
path: f.path,
|
|
760
|
+
exists: true,
|
|
761
|
+
lastCommitId: f.lastCommitId,
|
|
762
|
+
executable: f.executable,
|
|
763
|
+
size: f.size,
|
|
764
|
+
};
|
|
765
|
+
return f.kind === "text" ? { ...base, content: f.content } : { ...base, withheld: f.kind };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
reg(
|
|
769
|
+
"gitlab_files_get",
|
|
770
|
+
'Read up to 50 files at a branch, tag or commit: {ref, headSha, files:[{path, exists, lastCommitId, executable, size, content}]}. A binary or over-192-KiB file is reported with `withheld` instead of content, and the content of all the files together is capped at 192 KiB: the first file that does not fit, and every file after it, is reported with `withheld: "budget"` and no content.',
|
|
771
|
+
projectPathArg.extend({
|
|
772
|
+
ref: gitlabRefSchema,
|
|
773
|
+
paths: z.array(commitFilePathSchema).min(1).max(50),
|
|
774
|
+
}),
|
|
775
|
+
async (parsed) => {
|
|
776
|
+
const get = gitlabGet();
|
|
777
|
+
const project = encodeURIComponent(parsed.projectPath);
|
|
778
|
+
const sha = await refCommit(get, project, parsed.ref);
|
|
779
|
+
const files = await readGitlabFilesAt(
|
|
780
|
+
get,
|
|
781
|
+
project,
|
|
782
|
+
sha,
|
|
783
|
+
parsed.paths,
|
|
784
|
+
READ_LIMIT,
|
|
785
|
+
READ_LIMIT,
|
|
786
|
+
);
|
|
787
|
+
return jsonResult({ ref: parsed.ref, headSha: sha, files: files.map(fileView) });
|
|
788
|
+
},
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
const gitlabCommitInputSchema = projectPathArg.extend({
|
|
792
|
+
branch: gitlabRefSchema,
|
|
793
|
+
files: commitFilesSchema,
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
const repoScopeOf = (p: { projectPath: string }): { kind: string; value: string } => ({
|
|
797
|
+
kind: "repo",
|
|
798
|
+
value: p.projectPath,
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
registerPrepareTool(
|
|
802
|
+
"gitlab_commit_prepare",
|
|
803
|
+
{ scopeTargetOf: repoScopeOf },
|
|
804
|
+
`Read the state a gitlab_commit_create is bound to: the branch head and, for each file, whether it exists, its last_commit_id, whether it is executable and its current content. Refuses (no truncation) a commit that touches more than ${String(COMMIT_MAX_FILES)} files or whose old and new content together exceed 192 KiB, and any existing file that is not a text file. Takes the commit's own projectPath, branch and files.`,
|
|
805
|
+
gitlabCommitInputSchema,
|
|
806
|
+
async (parsed) => {
|
|
807
|
+
assertCommitFiles(parsed.files);
|
|
808
|
+
const get = gitlabGet();
|
|
809
|
+
const project = encodeURIComponent(parsed.projectPath);
|
|
810
|
+
const head = await branchHead(get, project, parsed.branch);
|
|
811
|
+
const read = await readGitlabFilesAt(
|
|
812
|
+
get,
|
|
813
|
+
project,
|
|
814
|
+
head,
|
|
815
|
+
parsed.files.map((f) => f.path),
|
|
816
|
+
READ_LIMIT,
|
|
817
|
+
);
|
|
818
|
+
// The files API reports no mode, so a symlink looks like a text file: read the tree entries and
|
|
819
|
+
// refuse a symlink, a submodule or a directory before anything is written.
|
|
820
|
+
await assertRegularTargets(get, project, head, read);
|
|
821
|
+
let oldBytes = 0;
|
|
822
|
+
const files = read.map((f) => {
|
|
823
|
+
if (!f.exists) return { path: f.path, exists: false };
|
|
824
|
+
if (f.kind !== "text") throw notOverwritable(f.path);
|
|
825
|
+
oldBytes += f.size;
|
|
826
|
+
return {
|
|
827
|
+
path: f.path,
|
|
828
|
+
exists: true,
|
|
829
|
+
lastCommitId: f.lastCommitId,
|
|
830
|
+
executable: f.executable,
|
|
831
|
+
oldContent: f.content,
|
|
832
|
+
};
|
|
833
|
+
});
|
|
834
|
+
assertCombinedSize(oldBytes, parsed.files);
|
|
835
|
+
return jsonResult({ headSha: head, files });
|
|
836
|
+
},
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
registerWriteTool(
|
|
840
|
+
"gitlab_commit_create",
|
|
841
|
+
{
|
|
842
|
+
mutates: "gitlab.commit.create",
|
|
843
|
+
// A commit moves a branch; the inverse needs the head and each file's last commit, captured first.
|
|
844
|
+
recoverable: false,
|
|
845
|
+
capturePreState: async (parsed) => {
|
|
846
|
+
const get = gitlabGet();
|
|
847
|
+
const project = encodeURIComponent(parsed.projectPath);
|
|
848
|
+
const head = await branchHead(get, project, parsed.branch);
|
|
849
|
+
const read = await readGitlabFilesAt(
|
|
850
|
+
get,
|
|
851
|
+
project,
|
|
852
|
+
head,
|
|
853
|
+
parsed.files.map((f) => f.path),
|
|
854
|
+
READ_LIMIT,
|
|
855
|
+
);
|
|
856
|
+
return {
|
|
857
|
+
branch: parsed.branch,
|
|
858
|
+
headSha: head,
|
|
859
|
+
files: read.map((f) => ({
|
|
860
|
+
path: f.path,
|
|
861
|
+
lastCommitId: f.exists ? f.lastCommitId : null,
|
|
862
|
+
})),
|
|
863
|
+
};
|
|
864
|
+
},
|
|
865
|
+
scopeTargetOf: repoScopeOf,
|
|
866
|
+
},
|
|
867
|
+
"Commit one or more whole text files to a branch as ONE commit. The branch head is checked against expectedHeadSha (the head the owner approved) before the write, but GitLab has no head-conditional commit, so that check is NOT atomic with the write. What protects the files is last_commit_id: each update carries the file's last_commit_id read at the approved head, and GitLab refuses it if that file changed since. A commit to OTHER files that lands between the check and the write is not detected before the write; movedHead: true in the result reports it afterwards (the new commit's parent is not the approved head). Never forced.",
|
|
868
|
+
gitlabCommitInputSchema.extend({
|
|
869
|
+
message: z.string().min(1).max(5000),
|
|
870
|
+
expectedHeadSha: fullShaSchema,
|
|
871
|
+
}),
|
|
872
|
+
async (parsed) => {
|
|
873
|
+
assertCommitFiles(parsed.files);
|
|
874
|
+
const get = gitlabGet();
|
|
875
|
+
const token = requireProcessEnv("GITLAB_PAT");
|
|
876
|
+
const project = encodeURIComponent(parsed.projectPath);
|
|
877
|
+
const expected = parsed.expectedHeadSha.toLowerCase();
|
|
878
|
+
// 1. The head AGAIN: the approval was for this commit, not for whatever the branch became.
|
|
879
|
+
const head = await branchHead(get, project, parsed.branch);
|
|
880
|
+
if (head !== expected) {
|
|
881
|
+
throw new Error(`branch head is ${head}, not the approved ${expected}`);
|
|
882
|
+
}
|
|
883
|
+
// 2. Each existing file AT that head: its last_commit_id goes on the update action.
|
|
884
|
+
const read = await readGitlabFilesAt(
|
|
885
|
+
get,
|
|
886
|
+
project,
|
|
887
|
+
head,
|
|
888
|
+
parsed.files.map((f) => f.path),
|
|
889
|
+
READ_LIMIT,
|
|
890
|
+
);
|
|
891
|
+
// The files API reports no mode, so a symlink looks like a text file: read the tree entries and
|
|
892
|
+
// refuse a symlink, a submodule or a directory before anything is written.
|
|
893
|
+
await assertRegularTargets(get, project, head, read);
|
|
894
|
+
let oldBytes = 0;
|
|
895
|
+
const actions = parsed.files.map((f, i) => {
|
|
896
|
+
const existing = read[i];
|
|
897
|
+
if (existing === undefined || !existing.exists) {
|
|
898
|
+
return { action: "create", file_path: f.path, content: f.content, encoding: "text" };
|
|
899
|
+
}
|
|
900
|
+
if (existing.kind !== "text") throw notOverwritable(f.path);
|
|
901
|
+
oldBytes += existing.size;
|
|
902
|
+
return {
|
|
903
|
+
action: "update",
|
|
904
|
+
file_path: f.path,
|
|
905
|
+
content: f.content,
|
|
906
|
+
encoding: "text",
|
|
907
|
+
last_commit_id: existing.lastCommitId,
|
|
908
|
+
};
|
|
909
|
+
});
|
|
910
|
+
assertCombinedSize(oldBytes, parsed.files);
|
|
911
|
+
// 3. One commit, no force, no start_branch.
|
|
912
|
+
const res = await glFetch(
|
|
913
|
+
token,
|
|
914
|
+
`/projects/${project}/repository/commits`,
|
|
915
|
+
jsonBody("POST", { branch: parsed.branch, commit_message: parsed.message, actions }),
|
|
916
|
+
);
|
|
917
|
+
if (!res.ok) {
|
|
918
|
+
throw new Error(`GitLab ${String(res.status)}: ${res.text.slice(0, 300)}`);
|
|
919
|
+
}
|
|
920
|
+
const made = (res.json ?? {}) as { id?: unknown; parent_ids?: unknown; web_url?: unknown };
|
|
921
|
+
// The head can move between step 1 and this POST (the API has no head-conditional commit):
|
|
922
|
+
// say so when the new commit's parent is not the head that was approved.
|
|
923
|
+
const movedHead = !(
|
|
924
|
+
Array.isArray(made.parent_ids) && made.parent_ids.map(String).includes(expected)
|
|
925
|
+
);
|
|
926
|
+
return jsonResult({
|
|
927
|
+
ok: true,
|
|
928
|
+
commitId: made.id,
|
|
929
|
+
branch: parsed.branch,
|
|
930
|
+
parent: expected,
|
|
931
|
+
webUrl: made.web_url,
|
|
932
|
+
movedHead,
|
|
933
|
+
});
|
|
934
|
+
},
|
|
935
|
+
);
|
|
710
936
|
}
|
|
@@ -2,21 +2,15 @@ import { z } from "zod";
|
|
|
2
2
|
import { cliArg } from "../../../shared/cli-json-kit.ts";
|
|
3
3
|
import type { ConsentServer } from "../../../shared/consent-kit.ts";
|
|
4
4
|
import { createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
5
|
+
import { isKubectlNotFound, projectDeployment } from "../../../shared/k8s-deployment-view.ts";
|
|
5
6
|
import {
|
|
6
7
|
createRegisterSimpleTool,
|
|
7
8
|
createZodToolRegistrar,
|
|
8
9
|
mcpJsonResult as jsonResult,
|
|
9
10
|
} from "../../../shared/mcp-tool-kit.ts";
|
|
11
|
+
import { withPrivateKubeconfig } from "../../../shared/private-kubeconfig.ts";
|
|
10
12
|
import { runCliJsonThrowing, runCliOkThrowing } from "../../../shared/run-cli-json.ts";
|
|
11
13
|
|
|
12
|
-
function requireKubeconfigPath(): string {
|
|
13
|
-
const p = process.env["KUBECONFIG"]?.trim();
|
|
14
|
-
if (p === undefined || p === "") {
|
|
15
|
-
throw new Error("KUBECONFIG env must be set to a kubeconfig file path");
|
|
16
|
-
}
|
|
17
|
-
return p;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
14
|
function kubectlBase(): string[] {
|
|
21
15
|
const args = ["kubectl"];
|
|
22
16
|
const ctx = process.env["KUBE_CONTEXT"]?.trim();
|
|
@@ -26,13 +20,17 @@ function kubectlBase(): string[] {
|
|
|
26
20
|
return args;
|
|
27
21
|
}
|
|
28
22
|
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Run `body` against a private copy of the kubeconfig `KUBECONFIG` names (shared/private-kubeconfig.ts):
|
|
25
|
+
* one tool call, one copy, so a call can neither change the owner's file nor race another call.
|
|
26
|
+
*/
|
|
27
|
+
function withKube<T>(body: (env: { readonly KUBECONFIG: string }) => Promise<T>): Promise<T> {
|
|
28
|
+
return withPrivateKubeconfig({ source: process.env["KUBECONFIG"] ?? "" }, body);
|
|
31
29
|
}
|
|
32
30
|
|
|
33
|
-
|
|
31
|
+
function kubectlJson(rest: string[]): Promise<unknown> {
|
|
34
32
|
const cmd = [...kubectlBase(), ...rest, "-o", "json"];
|
|
35
|
-
return runCliJsonThrowing(cmd,
|
|
33
|
+
return withKube((env) => runCliJsonThrowing(cmd, env));
|
|
36
34
|
}
|
|
37
35
|
|
|
38
36
|
const optionalNamespaceSchema = z.object({ namespace: cliArg.optional() });
|
|
@@ -46,6 +44,7 @@ async function k8sListNamespacedResource(p: { namespace?: string | undefined },
|
|
|
46
44
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
47
45
|
export const KUBERNETES_TOOL_NAMES = [
|
|
48
46
|
"k8s_pod_list",
|
|
47
|
+
"k8s_deployment_get",
|
|
49
48
|
"k8s_deployment_list",
|
|
50
49
|
"k8s_event_list",
|
|
51
50
|
"k8s_rollout_restart",
|
|
@@ -76,6 +75,27 @@ export function registerKubernetesTools(
|
|
|
76
75
|
(p) => k8sListNamespacedResource(p, "pods"),
|
|
77
76
|
);
|
|
78
77
|
|
|
78
|
+
reg(
|
|
79
|
+
"k8s_deployment_get",
|
|
80
|
+
"Get one deployment's state: {name, namespace, replicas, readyReplicas, generation, observedGeneration, restartedAt, images}. The before-state of a restart or a scale.",
|
|
81
|
+
z.object({ namespace: cliArg.optional(), name: cliArg }),
|
|
82
|
+
async (p) => {
|
|
83
|
+
let data: unknown;
|
|
84
|
+
try {
|
|
85
|
+
// kubectlJson builds on kubectlBase(), so the configured KUBE_CONTEXT applies as it does
|
|
86
|
+
// to every other call here.
|
|
87
|
+
data = await kubectlJson(["get", "deployment", p.name, "-n", p.namespace ?? "default"]);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// Exit code of a missing object is not documented, so NotFound is read from the message.
|
|
90
|
+
if (e instanceof Error && isKubectlNotFound(e.message)) {
|
|
91
|
+
throw new Error("deployment not found", { cause: e });
|
|
92
|
+
}
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
return jsonResult(projectDeployment(data));
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
79
99
|
reg("k8s_deployment_list", "List deployments in a namespace.", optionalNamespaceSchema, (p) =>
|
|
80
100
|
k8sListNamespacedResource(p, "deployments"),
|
|
81
101
|
);
|
|
@@ -100,7 +120,7 @@ export function registerKubernetesTools(
|
|
|
100
120
|
async (p) => {
|
|
101
121
|
const ns = p.namespace ?? "default";
|
|
102
122
|
const cmd = [...kubectlBase(), "rollout", "restart", p.resourceType, p.name, "-n", ns];
|
|
103
|
-
await runCliOkThrowing(cmd,
|
|
123
|
+
await withKube((env) => runCliOkThrowing(cmd, env));
|
|
104
124
|
return jsonResult({ ok: true });
|
|
105
125
|
},
|
|
106
126
|
);
|
|
@@ -124,7 +144,7 @@ export function registerKubernetesTools(
|
|
|
124
144
|
async (p) => {
|
|
125
145
|
const ns = p.namespace ?? "default";
|
|
126
146
|
const cmd = [...kubectlBase(), "delete", "pod", p.podName, "-n", ns];
|
|
127
|
-
await runCliOkThrowing(cmd,
|
|
147
|
+
await withKube((env) => runCliOkThrowing(cmd, env));
|
|
128
148
|
return jsonResult({ ok: true });
|
|
129
149
|
},
|
|
130
150
|
);
|
|
@@ -153,7 +173,7 @@ export function registerKubernetesTools(
|
|
|
153
173
|
ns,
|
|
154
174
|
`--replicas=${String(p.replicas)}`,
|
|
155
175
|
];
|
|
156
|
-
await runCliOkThrowing(cmd,
|
|
176
|
+
await withKube((env) => runCliOkThrowing(cmd, env));
|
|
157
177
|
return jsonResult({ ok: true });
|
|
158
178
|
},
|
|
159
179
|
);
|
|
@@ -102,9 +102,17 @@ export const SLACK_TOOL_NAMES = [
|
|
|
102
102
|
"slack_message_post_dm",
|
|
103
103
|
"slack_user_info",
|
|
104
104
|
"slack_chat_post",
|
|
105
|
+
"slack_views_open",
|
|
105
106
|
"slack_socket_open",
|
|
106
107
|
] as const;
|
|
107
108
|
|
|
109
|
+
/** Largest serialised `blocks` array / `view` object Slack accepts from this connector (16 KiB). */
|
|
110
|
+
export const SLACK_PAYLOAD_MAX_BYTES = 16 * 1024;
|
|
111
|
+
|
|
112
|
+
function serialisedBytes(value: unknown): number {
|
|
113
|
+
return new TextEncoder().encode(JSON.stringify(value)).length;
|
|
114
|
+
}
|
|
115
|
+
|
|
108
116
|
export function registerSlackTools(
|
|
109
117
|
server: ConsentServer & { tool: (...args: never) => unknown },
|
|
110
118
|
): void {
|
|
@@ -122,7 +130,7 @@ export function registerSlackTools(
|
|
|
122
130
|
scopeEnv: "NIMBUS_MCP_SLACK_WRITE_SCOPE",
|
|
123
131
|
// `user` scopes a direct message by the recipient's user id, which is all a DM has before
|
|
124
132
|
// `conversations.open` creates its channel.
|
|
125
|
-
scopeKinds: ["channel", "user"],
|
|
133
|
+
scopeKinds: ["channel", "user", "view"],
|
|
126
134
|
});
|
|
127
135
|
|
|
128
136
|
const slackChannelListSchema = z.object({
|
|
@@ -341,7 +349,19 @@ export function registerSlackTools(
|
|
|
341
349
|
),
|
|
342
350
|
);
|
|
343
351
|
|
|
344
|
-
const slackChatPostSchema = z.object({
|
|
352
|
+
const slackChatPostSchema = z.object({
|
|
353
|
+
channel: z.string().min(1),
|
|
354
|
+
text: z.string().min(1),
|
|
355
|
+
// Block Kit blocks, sent to Slack verbatim beside `text` (which stays the notification fallback).
|
|
356
|
+
blocks: z
|
|
357
|
+
.array(z.looseObject({ type: z.string() }))
|
|
358
|
+
.min(1)
|
|
359
|
+
.max(50)
|
|
360
|
+
.refine((b) => serialisedBytes(b) <= SLACK_PAYLOAD_MAX_BYTES, {
|
|
361
|
+
message: `blocks must serialise to at most ${SLACK_PAYLOAD_MAX_BYTES} bytes`,
|
|
362
|
+
})
|
|
363
|
+
.optional(),
|
|
364
|
+
});
|
|
345
365
|
registerWriteTool(
|
|
346
366
|
"slack_chat_post",
|
|
347
367
|
{
|
|
@@ -351,12 +371,43 @@ export function registerSlackTools(
|
|
|
351
371
|
},
|
|
352
372
|
"Post an operational bot message to a channel (ChatOps reply surface; bot token).",
|
|
353
373
|
slackChatPostSchema,
|
|
354
|
-
async (parsed) =>
|
|
355
|
-
|
|
374
|
+
async (parsed) => {
|
|
375
|
+
const body: Record<string, unknown> = { channel: parsed.channel, text: parsed.text };
|
|
376
|
+
if (parsed.blocks !== undefined) {
|
|
377
|
+
body["blocks"] = parsed.blocks;
|
|
378
|
+
}
|
|
379
|
+
return slackInvokeJsonWithToken(
|
|
356
380
|
requireProcessEnv("SLACK_BOT_TOKEN"),
|
|
357
381
|
"chat.postMessage",
|
|
358
|
-
|
|
382
|
+
body,
|
|
359
383
|
"Slack chat.postMessage",
|
|
384
|
+
);
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const slackViewsOpenSchema = z.object({
|
|
389
|
+
trigger_id: z.string().min(1),
|
|
390
|
+
view: z
|
|
391
|
+
.looseObject({ type: z.literal("modal") })
|
|
392
|
+
.refine((v) => serialisedBytes(v) <= SLACK_PAYLOAD_MAX_BYTES, {
|
|
393
|
+
message: `view must serialise to at most ${SLACK_PAYLOAD_MAX_BYTES} bytes`,
|
|
394
|
+
}),
|
|
395
|
+
});
|
|
396
|
+
registerWriteTool(
|
|
397
|
+
"slack_views_open",
|
|
398
|
+
{
|
|
399
|
+
mutates: "slack.views.open",
|
|
400
|
+
recoverable: true,
|
|
401
|
+
scopeTargetOf: () => ({ kind: "view", value: "modal" }),
|
|
402
|
+
},
|
|
403
|
+
"Open a confirmation modal for a user who clicked a ChatOps card button (ChatOps operational surface; bot token).",
|
|
404
|
+
slackViewsOpenSchema,
|
|
405
|
+
async (parsed) =>
|
|
406
|
+
slackInvokeJsonWithToken(
|
|
407
|
+
requireProcessEnv("SLACK_BOT_TOKEN"),
|
|
408
|
+
"views.open",
|
|
409
|
+
{ trigger_id: parsed.trigger_id, view: parsed.view },
|
|
410
|
+
"Slack views.open",
|
|
360
411
|
),
|
|
361
412
|
);
|
|
362
413
|
|
|
@@ -2,8 +2,9 @@ import { z } from "zod";
|
|
|
2
2
|
import { type ConsentServer, createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
3
3
|
import { cursorListInputSchema } from "../../../shared/cursor-list-tool.ts";
|
|
4
4
|
import { requiredEnv } from "../../../shared/env-json-api.ts";
|
|
5
|
+
import { guardedFetch } from "../../../shared/guarded-fetch.ts";
|
|
5
6
|
import { searchToolInputSchema } from "../../../shared/mcp-search-tool.ts";
|
|
6
|
-
import {
|
|
7
|
+
import { mcpJsonResult as jsonResult } from "../../../shared/mcp-tool-kit.ts";
|
|
7
8
|
import {
|
|
8
9
|
runReadOnlyMcpConnector,
|
|
9
10
|
type ZodToolRegistrar,
|
|
@@ -26,7 +27,9 @@ function authHeader(): Record<string, string> {
|
|
|
26
27
|
*/
|
|
27
28
|
async function executeStatement(statement: string): Promise<unknown> {
|
|
28
29
|
const url = `https://${requiredEnv("SNOWFLAKE_ACCOUNT")}.snowflakecomputing.com/api/v2/statements`;
|
|
29
|
-
|
|
30
|
+
// guardedFetch: the Bearer token must never follow a redirect to another origin, and a redirect
|
|
31
|
+
// on this POST (which may be a write) is refused rather than replayed.
|
|
32
|
+
const res = await guardedFetch("Snowflake", url, {
|
|
30
33
|
method: "POST",
|
|
31
34
|
headers: authHeader(),
|
|
32
35
|
body: JSON.stringify({ statement, timeout: 60 }),
|
|
@@ -55,6 +58,23 @@ function sfLiteral(v: string): string {
|
|
|
55
58
|
return `'${v.replaceAll("'", "''")}'`;
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
/** The parts of `db.schema.name`, each already vetted by {@link assertSfIdentifier}. */
|
|
62
|
+
function splitObjectName(object: string): { db: string; schema: string; name: string } {
|
|
63
|
+
// The name is placed in string literals, where Snowflake reads a backslash as an escape.
|
|
64
|
+
if (object.includes("\\")) throw new Error("object must not contain a backslash");
|
|
65
|
+
const parts = assertSfIdentifier(object, "object").split(".");
|
|
66
|
+
const [db, schema, name] = parts;
|
|
67
|
+
if (parts.length !== 3 || db === undefined || schema === undefined || name === undefined) {
|
|
68
|
+
throw new Error("object must be a fully qualified name: database.schema.name");
|
|
69
|
+
}
|
|
70
|
+
return { db, schema, name };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** An identifier part's name as Snowflake stores it: a quoted part verbatim, else upper-cased. */
|
|
74
|
+
function storedName(part: string): string {
|
|
75
|
+
return part.startsWith('"') ? part.slice(1, -1) : part.toUpperCase();
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
const TABLES_SQL =
|
|
59
79
|
"SELECT table_catalog AS database_name, table_schema AS schema_name, table_name, " +
|
|
60
80
|
"row_count, last_altered FROM information_schema.tables WHERE table_schema <> 'INFORMATION_SCHEMA'";
|
|
@@ -108,6 +128,43 @@ function tableKey(row: Record<string, unknown>): string {
|
|
|
108
128
|
return `${db}.${schema}.${table}`;
|
|
109
129
|
}
|
|
110
130
|
|
|
131
|
+
/**
|
|
132
|
+
* The metadata a comment or tag change is previewed against: the object's COMMENT and its tags,
|
|
133
|
+
* direct and inherited. Two statements, both live (never ACCOUNT_USAGE, whose TAG_REFERENCES lags
|
|
134
|
+
* up to 120 minutes):
|
|
135
|
+
* - `SHOW TABLES|VIEWS LIKE '<name>' IN SCHEMA db.schema` for the `comment` column. The LIKE pattern
|
|
136
|
+
* supports the wildcards % and _ and the page documents no escape for them
|
|
137
|
+
* (https://docs.snowflake.com/en/sql-reference/sql/show-tables), so none is relied on: the pattern
|
|
138
|
+
* only NARROWS the rows, and the object is picked by exact name in code.
|
|
139
|
+
* - `INFORMATION_SCHEMA.TAG_REFERENCES('db.schema.name', 'TABLE')`; the domain TABLE covers views
|
|
140
|
+
* (https://docs.snowflake.com/en/sql-reference/functions/tag_references).
|
|
141
|
+
* Metadata only: no row data is ever selected.
|
|
142
|
+
*/
|
|
143
|
+
async function objectMetadata(
|
|
144
|
+
objectType: "table" | "view",
|
|
145
|
+
object: string,
|
|
146
|
+
): Promise<Record<string, unknown>> {
|
|
147
|
+
const { db, schema, name } = splitObjectName(object);
|
|
148
|
+
const verb = objectType === "view" ? "VIEWS" : "TABLES";
|
|
149
|
+
const shown = rowsFromStatementsResponse(
|
|
150
|
+
await executeStatement(
|
|
151
|
+
`SHOW ${verb} LIKE ${sfLiteral(name.startsWith('"') ? name.slice(1, -1) : name)} IN SCHEMA ${db}.${schema}`,
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
const wanted = storedName(name);
|
|
155
|
+
const row = shown.find((r) => r["name"] === wanted);
|
|
156
|
+
if (row === undefined) throw new Error(`Snowflake ${objectType} not found: ${object}`);
|
|
157
|
+
const tagRows = rowsFromStatementsResponse(
|
|
158
|
+
await executeStatement(
|
|
159
|
+
`SELECT tag_name, tag_value FROM TABLE(${db}.INFORMATION_SCHEMA.TAG_REFERENCES(${sfLiteral(object)}, 'TABLE'))`,
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
const tags = tagRows
|
|
163
|
+
.map((t) => ({ name: String(t["tag_name"] ?? ""), value: t["tag_value"] ?? null }))
|
|
164
|
+
.sort((a, b) => a.name.localeCompare(b.name) || String(a.value).localeCompare(String(b.value)));
|
|
165
|
+
return { object, objectType, comment: row["comment"] ?? "", tags };
|
|
166
|
+
}
|
|
167
|
+
|
|
111
168
|
/**
|
|
112
169
|
* Register the three read-only Snowflake tools on `reg`. Exported (rather than inlined into the
|
|
113
170
|
* `runReadOnlyMcpConnector` call) so the tool handlers can be exercised in unit tests without
|
|
@@ -166,6 +223,16 @@ export function registerSnowflakeTools(reg: ZodToolRegistrar, server: unknown):
|
|
|
166
223
|
},
|
|
167
224
|
);
|
|
168
225
|
|
|
226
|
+
reg(
|
|
227
|
+
"snowflake_object_metadata_get",
|
|
228
|
+
"Get a table's or view's COMMENT and tags (direct and inherited) by fully qualified name: {object, objectType, comment, tags:[{name, value}]}. The before-state of snowflake_comment_set and snowflake_tag_set; objectType defaults to table. Metadata only, never row data.",
|
|
229
|
+
z.object({
|
|
230
|
+
objectType: z.enum(["table", "view"]).optional(),
|
|
231
|
+
object: z.string().min(1),
|
|
232
|
+
}),
|
|
233
|
+
async (p) => jsonResult(await objectMetadata(p.objectType ?? "table", p.object)),
|
|
234
|
+
);
|
|
235
|
+
|
|
169
236
|
registerWriteTool(
|
|
170
237
|
"snowflake_tag_set",
|
|
171
238
|
{
|
|
@@ -55,10 +55,14 @@ function serviceUrlBase(): string {
|
|
|
55
55
|
return base.endsWith("/") ? base : `${base}/`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Send a message activity to a Teams conversation via the Bot Framework. `attachments` (an Adaptive
|
|
60
|
+
* Card) goes into the activity body only when given.
|
|
61
|
+
*/
|
|
59
62
|
export async function teamsBotSendActivity(
|
|
60
63
|
conversationId: string,
|
|
61
64
|
text: string,
|
|
65
|
+
attachments?: readonly unknown[],
|
|
62
66
|
): Promise<GraphLikeResult> {
|
|
63
67
|
const appId = requireProcessEnv("TEAMS_BOT_APP_ID");
|
|
64
68
|
const appPassword = requireProcessEnv("TEAMS_BOT_APP_PASSWORD");
|
|
@@ -67,6 +71,10 @@ export async function teamsBotSendActivity(
|
|
|
67
71
|
return fetchBearerAuthorizedJson(url, token, {
|
|
68
72
|
method: "POST",
|
|
69
73
|
headers: { "Content-Type": "application/json" },
|
|
70
|
-
body: JSON.stringify(
|
|
74
|
+
body: JSON.stringify(
|
|
75
|
+
attachments === undefined
|
|
76
|
+
? { type: "message", text }
|
|
77
|
+
: { type: "message", text, attachments },
|
|
78
|
+
),
|
|
71
79
|
});
|
|
72
80
|
}
|
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
} from "../../../shared/mcp-tool-kit.ts";
|
|
15
15
|
import { teamsBotSendActivity } from "./bot-send.ts";
|
|
16
16
|
|
|
17
|
+
/** Largest serialised `attachments` array `teams_chat_post` accepts (28 KiB). */
|
|
18
|
+
export const TEAMS_ATTACHMENTS_MAX_BYTES = 28 * 1024;
|
|
19
|
+
|
|
17
20
|
const GRAPH = "https://graph.microsoft.com/v1.0";
|
|
18
21
|
|
|
19
22
|
async function graphRequest(
|
|
@@ -265,6 +268,20 @@ export function registerTeamsTools(
|
|
|
265
268
|
const teamsChatPostSchema = z.object({
|
|
266
269
|
conversationId: z.string().min(1),
|
|
267
270
|
text: z.string().min(1),
|
|
271
|
+
// Exactly one Adaptive Card, sent in the activity verbatim; at most 28 KiB serialised.
|
|
272
|
+
attachments: z
|
|
273
|
+
.array(
|
|
274
|
+
z.object({
|
|
275
|
+
contentType: z.literal("application/vnd.microsoft.card.adaptive"),
|
|
276
|
+
content: z.record(z.string(), z.unknown()),
|
|
277
|
+
}),
|
|
278
|
+
)
|
|
279
|
+
.length(1)
|
|
280
|
+
.refine(
|
|
281
|
+
(a) => new TextEncoder().encode(JSON.stringify(a)).length <= TEAMS_ATTACHMENTS_MAX_BYTES,
|
|
282
|
+
{ message: `attachments must serialise to at most ${TEAMS_ATTACHMENTS_MAX_BYTES} bytes` },
|
|
283
|
+
)
|
|
284
|
+
.optional(),
|
|
268
285
|
});
|
|
269
286
|
registerWriteTool(
|
|
270
287
|
"teams_chat_post",
|
|
@@ -276,7 +293,7 @@ export function registerTeamsTools(
|
|
|
276
293
|
"Post an operational bot message to a Teams conversation (ChatOps reply surface; bot app creds).",
|
|
277
294
|
teamsChatPostSchema,
|
|
278
295
|
async (parsed) => {
|
|
279
|
-
const r = await teamsBotSendActivity(parsed.conversationId, parsed.text);
|
|
296
|
+
const r = await teamsBotSendActivity(parsed.conversationId, parsed.text, parsed.attachments);
|
|
280
297
|
return graphListResult(r);
|
|
281
298
|
},
|
|
282
299
|
);
|
package/package.json
CHANGED
package/shared/audit-chain.ts
CHANGED
|
@@ -23,7 +23,9 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
23
23
|
* `out of scope` and `budget exhausted` are decided before any prompt. `budget exhausted after
|
|
24
24
|
* approval` follows an `accepted` entry: writes in flight together spent the last of the budget
|
|
25
25
|
* while this one's prompt was open, so a human approved a write that then did not run. A client
|
|
26
|
-
* that cannot prompt is never offered a write tool at all, so it leaves no entry.
|
|
26
|
+
* that cannot prompt is never offered a write tool at all, so it leaves no entry. `prepared` is a
|
|
27
|
+
* prepare-class tool (consent-kit.ts) that ran in scope: it changes nothing and spends no budget,
|
|
28
|
+
* but the operator can see what was read on a write's behalf.
|
|
27
29
|
*/
|
|
28
30
|
export type AuditOutcome =
|
|
29
31
|
| "requested"
|
|
@@ -31,7 +33,8 @@ export type AuditOutcome =
|
|
|
31
33
|
| "declined"
|
|
32
34
|
| "refused"
|
|
33
35
|
| "executed"
|
|
34
|
-
| "failed"
|
|
36
|
+
| "failed"
|
|
37
|
+
| "prepared";
|
|
35
38
|
|
|
36
39
|
export type AuditEntry = {
|
|
37
40
|
readonly ts: string;
|