@edda-business/mcp 0.64.0 → 0.66.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/package.json +6 -2
- package/src/client.js +26 -0
- package/src/index.js +9 -2
- package/src/server.js +95 -61
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edda-business/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
4
|
"description": "Edda — the company data layer for AI agents.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
6
9
|
"repository": {
|
|
7
10
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/
|
|
11
|
+
"url": "git+https://github.com/EddaBusiness/edda.git",
|
|
9
12
|
"directory": "apps/mcp"
|
|
10
13
|
},
|
|
11
14
|
"type": "module",
|
|
@@ -21,6 +24,7 @@
|
|
|
21
24
|
"start": "node src/index.js",
|
|
22
25
|
"dev:http": "node --watch src/http.js",
|
|
23
26
|
"start:http": "node src/http.js",
|
|
27
|
+
"test": "node --test tests/*.test.mjs",
|
|
24
28
|
"typecheck": "echo 'no ts in mcp — skipping'"
|
|
25
29
|
},
|
|
26
30
|
"keywords": [
|
package/src/client.js
CHANGED
|
@@ -29,6 +29,15 @@ const envKey = () => resolvedEnv("EDDA_API_KEY") ?? resolvedEnv("NOUS_API_KEY");
|
|
|
29
29
|
const envUrl = () => resolvedEnv("EDDA_API_URL") ?? resolvedEnv("NOUS_API_URL");
|
|
30
30
|
const API_URL = envUrl() || "https://api.opennous.cloud";
|
|
31
31
|
|
|
32
|
+
// EfB seat boundary (EDDA_MCP_READONLY=1|true|yes — the stable flag name Hermes EfB box
|
|
33
|
+
// profiles set). In this mode the server registers ONLY search/read (see server.js), and the
|
|
34
|
+
// client FAILS CLOSED: it requires an explicitly configured API URL and key, and never falls
|
|
35
|
+
// back to the vendor cloud default — a seat's company questions must only ever reach the
|
|
36
|
+
// company's own instance. Read per-call so the flag is testable and honored at request time.
|
|
37
|
+
export function isReadOnly() {
|
|
38
|
+
return /^(1|true|yes)$/i.test(process.env.EDDA_MCP_READONLY ?? "");
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
// Per-request key context for the hosted HTTP server. Empty in stdio mode.
|
|
33
42
|
export const apiKeyStore = new AsyncLocalStorage();
|
|
34
43
|
|
|
@@ -61,7 +70,10 @@ function currentApiKey() {
|
|
|
61
70
|
|
|
62
71
|
// Resolve the API base per call: env → config.json (set by the CLI login on self-host) → cloud
|
|
63
72
|
// default. So a self-hoster who logs in via the CLI gets the MCP pointed at their own instance.
|
|
73
|
+
// In read-only seat mode there is NO cloud default — an unconfigured URL resolves to undefined
|
|
74
|
+
// and request() refuses to issue anything.
|
|
64
75
|
function currentApiUrl() {
|
|
76
|
+
if (isReadOnly()) return envUrl() ?? fileApiUrl();
|
|
65
77
|
return envUrl() ?? fileApiUrl() ?? "https://api.opennous.cloud";
|
|
66
78
|
}
|
|
67
79
|
|
|
@@ -74,6 +86,12 @@ export function validateConfig() {
|
|
|
74
86
|
"to set up from scratch), or set EDDA_API_KEY."
|
|
75
87
|
);
|
|
76
88
|
}
|
|
89
|
+
if (isReadOnly() && !currentApiUrl()) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
"Read-only seat mode (EDDA_MCP_READONLY) requires an explicit company API URL — set EDDA_API_URL " +
|
|
92
|
+
"or log in with `edda login --url <company-api-url>`. There is no cloud fallback in this mode."
|
|
93
|
+
);
|
|
94
|
+
}
|
|
77
95
|
}
|
|
78
96
|
|
|
79
97
|
async function request(method, path, { body, query } = {}) {
|
|
@@ -81,6 +99,14 @@ async function request(method, path, { body, query } = {}) {
|
|
|
81
99
|
if (!apiKey) {
|
|
82
100
|
throw new Error("Missing Edda API key. Pass it as an Authorization: Bearer header.");
|
|
83
101
|
}
|
|
102
|
+
if (isReadOnly() && !currentApiUrl()) {
|
|
103
|
+
// Fail closed BEFORE any network I/O: a seat without an explicit company URL must not
|
|
104
|
+
// leak its question (or key) to the vendor cloud default.
|
|
105
|
+
throw new Error(
|
|
106
|
+
"Read-only seat mode requires an explicit company API URL (EDDA_API_URL or the login config's apiUrl). " +
|
|
107
|
+
"No request was made."
|
|
108
|
+
);
|
|
109
|
+
}
|
|
84
110
|
|
|
85
111
|
const url = new URL(path, currentApiUrl());
|
|
86
112
|
|
package/src/index.js
CHANGED
|
@@ -10,13 +10,20 @@
|
|
|
10
10
|
* Required env:
|
|
11
11
|
* EDDA_API_KEY — workspace API key (Sources -> API Keys). Encodes the workspace + scope.
|
|
12
12
|
* Optional:
|
|
13
|
-
* EDDA_API_URL — API base URL for your instance, e.g. https://api
|
|
13
|
+
* EDDA_API_URL — API base URL for your instance, e.g. https://api.<client>
|
|
14
14
|
* (Legacy NOUS_API_KEY / NOUS_API_URL are still accepted as a fallback.)
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
18
18
|
import { validateConfig } from "./client.js";
|
|
19
|
-
import { createServer } from "./server.js";
|
|
19
|
+
import { createServer, SERVER_VERSION } from "./server.js";
|
|
20
|
+
|
|
21
|
+
// Edda Skeleton calls this after installation. It must be entirely local: no config lookup,
|
|
22
|
+
// validation or network access can make an installed package look unavailable.
|
|
23
|
+
if (process.argv.includes("--version") || process.argv.includes("-V")) {
|
|
24
|
+
console.log(SERVER_VERSION);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
20
27
|
|
|
21
28
|
// Advisory only — don't hard-exit if there's no key yet. The user may register
|
|
22
29
|
// the MCP first and sign in after (`npx @edda-business/cli login`); the server must
|
package/src/server.js
CHANGED
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
* + embeds them. You only RETRIEVE them (they surface in search tagged ·distilled).
|
|
21
21
|
*
|
|
22
22
|
* TOOLS:
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* search RETRIEVE across BOTH natures (documents + distilled), kind filter
|
|
24
|
+
* read read one full wiki page (by path from a search result, or id)
|
|
25
|
+
* submit_company_candidate STAGE one user-approved file into the company Inbox (the River places it)
|
|
26
|
+
* reconcile PROCESS the company Inbox — file staged items into the right folders
|
|
27
|
+
* update edit an existing wiki page (replace or append)
|
|
28
|
+
* create organize the wiki tree
|
|
28
29
|
*
|
|
29
30
|
* Sources (connecting Slack/HubSpot/… so distillation happens) are wired in the Edda web
|
|
30
31
|
* app, not here — the agent only reads and writes knowledge.
|
|
@@ -32,9 +33,9 @@
|
|
|
32
33
|
|
|
33
34
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
34
35
|
import { z } from "zod";
|
|
35
|
-
import { get, post, patch } from "./client.js";
|
|
36
|
+
import { get, post, patch, isReadOnly } from "./client.js";
|
|
36
37
|
|
|
37
|
-
export const SERVER_VERSION = "0.
|
|
38
|
+
export const SERVER_VERSION = "0.66.0";
|
|
38
39
|
|
|
39
40
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
40
41
|
|
|
@@ -63,7 +64,7 @@ async function resolvePathToId(path) {
|
|
|
63
64
|
const pages = list?.pages ?? [];
|
|
64
65
|
const base = norm(String(path).split("/").pop());
|
|
65
66
|
let cands = pages.filter((p) => norm(p.name) === base);
|
|
66
|
-
if (!cands.length) return { error: `No company page named "${String(path).split("/").pop()}". Use
|
|
67
|
+
if (!cands.length) return { error: `No company page named "${String(path).split("/").pop()}". Use search to find the exact path first.` };
|
|
67
68
|
if (cands.length > 1) {
|
|
68
69
|
// Prefer a candidate whose folder slug appears in the requested path.
|
|
69
70
|
const pref = cands.filter((p) => p.folder && norm(path).includes(norm(p.folder)));
|
|
@@ -80,6 +81,11 @@ async function resolvePathToId(path) {
|
|
|
80
81
|
// ─── factory ──────────────────────────────────────────────────────────────────
|
|
81
82
|
|
|
82
83
|
export function createServer() {
|
|
84
|
+
// EfB seat boundary: with EDDA_MCP_READONLY set (the flag Hermes EfB box profiles use),
|
|
85
|
+
// the personal agent gets EXACTLY search + read — the write tools (push/update/create) are
|
|
86
|
+
// never registered, so a member key that could overwrite shared pages can't do so from a
|
|
87
|
+
// seat. Without the flag, registration below is untouched five-tool behavior.
|
|
88
|
+
const readOnly = isReadOnly();
|
|
83
89
|
const server = new McpServer({
|
|
84
90
|
name: "edda",
|
|
85
91
|
version: SERVER_VERSION,
|
|
@@ -87,7 +93,7 @@ export function createServer() {
|
|
|
87
93
|
"Edda — the company's shared knowledge for AI agents. Search the company wiki (declared " +
|
|
88
94
|
"policies, playbooks, decisions) AND distilled knowledge from connected sources (Slack, " +
|
|
89
95
|
"HubSpot, calls) in one call; read a full page; and contribute pages/folders back. Reach " +
|
|
90
|
-
"for
|
|
96
|
+
"for search before answering from generic knowledge. (Personal files live on the " +
|
|
91
97
|
"user's own disk; this is the shared company layer only.)",
|
|
92
98
|
icons: [
|
|
93
99
|
{ src: "https://opennous.cloud/newlogoP.png", mimeType: "image/png", sizes: ["64x64"] },
|
|
@@ -95,18 +101,18 @@ export function createServer() {
|
|
|
95
101
|
});
|
|
96
102
|
|
|
97
103
|
// ===========================================================================
|
|
98
|
-
// TOOL:
|
|
104
|
+
// TOOL: search — POST /v2/company/search
|
|
99
105
|
// The primary read surface. Hybrid retrieval (RRF + rerank) fused across the DECLARED wiki
|
|
100
106
|
// and the DERIVED distilled layer, ACL-filtered. `kind` narrows to one nature.
|
|
101
107
|
// ===========================================================================
|
|
102
108
|
server.tool(
|
|
103
|
-
"
|
|
109
|
+
"search",
|
|
104
110
|
"Search the COMPANY'S SHARED KNOWLEDGE in one call — both natures at once: the DECLARED wiki " +
|
|
105
111
|
"(policies, playbooks, decisions, SOPs, how-we-work) AND DERIVED knowledge distilled from " +
|
|
106
112
|
"connected sources (Slack, HubSpot, Stripe, calls). This is the default retrieval tool: reach " +
|
|
107
113
|
"for it before answering from generic knowledge about how the company works or what happened. " +
|
|
108
114
|
"Each hit is tagged [company] (a wiki page) or [company·distilled] (from activity), with a " +
|
|
109
|
-
"snippet; wiki hits include a `path:` you can pass to
|
|
115
|
+
"snippet; wiki hits include a `path:` you can pass to read for the full text. " +
|
|
110
116
|
"Narrow with `kind`: 'documents' (wiki only), 'distilled' (activity only), or 'all' (default).",
|
|
111
117
|
{
|
|
112
118
|
question: z.string().describe("Natural-language query."),
|
|
@@ -132,7 +138,7 @@ export function createServer() {
|
|
|
132
138
|
const tag = declared ? "[company]" : "[company·distilled]";
|
|
133
139
|
const from = d.source_member ? ` · from ${d.source_member}` : "";
|
|
134
140
|
lines.push(`${tag} ${String(d.name ?? "").replace(/\.md$/, "")}${from} (${pct(d.similarity)})`);
|
|
135
|
-
if (declared && d.ref) lines.push(` path: ${d.ref}`); // pass to
|
|
141
|
+
if (declared && d.ref) lines.push(` path: ${d.ref}`); // pass to read
|
|
136
142
|
else if (d.ref) lines.push(` source: ${d.ref}`); // distilled: origin, read-only
|
|
137
143
|
if (d.snippet) lines.push(` ${d.snippet}`);
|
|
138
144
|
lines.push("");
|
|
@@ -142,12 +148,12 @@ export function createServer() {
|
|
|
142
148
|
);
|
|
143
149
|
|
|
144
150
|
// ===========================================================================
|
|
145
|
-
// TOOL:
|
|
146
|
-
// Read one full wiki page. Take the `path:` from a
|
|
151
|
+
// TOOL: read — GET /v2/company/pages/:id
|
|
152
|
+
// Read one full wiki page. Take the `path:` from a search result, or an id.
|
|
147
153
|
// ===========================================================================
|
|
148
154
|
server.tool(
|
|
149
|
-
"
|
|
150
|
-
"Read the FULL text of one company wiki page —
|
|
155
|
+
"read",
|
|
156
|
+
"Read the FULL text of one company wiki page — search returns snippets; use this to " +
|
|
151
157
|
"get the whole document. Pass the `path:` shown under a [company] search hit (e.g. " +
|
|
152
158
|
"'Company/Policies/Refund Policy.md'), or an `id` if you already have one. Also returns the " +
|
|
153
159
|
"page's LINK GRAPH — outgoing [[links]] and backlinks — so you can TRAVERSE the wiki to " +
|
|
@@ -164,7 +170,7 @@ export function createServer() {
|
|
|
164
170
|
if (res.error) return text(res.error);
|
|
165
171
|
pageId = res.id;
|
|
166
172
|
}
|
|
167
|
-
if (!pageId) return text("Tell me which page to read — pass the `path` from a
|
|
173
|
+
if (!pageId) return text("Tell me which page to read — pass the `path` from a search result (its `path:` line), or an `id`.");
|
|
168
174
|
try {
|
|
169
175
|
const r = await get(`/v2/company/pages/${encodeURIComponent(pageId)}`);
|
|
170
176
|
const p = r?.page;
|
|
@@ -175,74 +181,102 @@ export function createServer() {
|
|
|
175
181
|
const outgoing = p.ingest?.outgoing ?? [];
|
|
176
182
|
const backlinks = p.ingest?.backlinks ?? [];
|
|
177
183
|
if (outgoing.length || backlinks.length) {
|
|
178
|
-
out.push("", "---", "Links (
|
|
184
|
+
out.push("", "---", "Links (read on any of these to traverse):");
|
|
179
185
|
if (outgoing.length) out.push(" → links to: " + outgoing.map((l) => `${l.target}${l.resolved ? "" : " (unresolved)"}`).join(", "));
|
|
180
186
|
if (backlinks.length) out.push(" ← linked from: " + backlinks.map((b) => b.source).join(", "));
|
|
181
187
|
}
|
|
182
188
|
return text(out.join("\n"));
|
|
183
189
|
} catch (e) {
|
|
184
190
|
const msg = String(e?.message || e);
|
|
185
|
-
if (msg.includes("not_found") || msg.includes("(404)")) return text("No page with that id — use
|
|
191
|
+
if (msg.includes("not_found") || msg.includes("(404)")) return text("No page with that id — use search to find the path, then read it.");
|
|
186
192
|
if (msg.includes("forbidden") || msg.includes("(403)")) return text("You don't have access to read that company page.");
|
|
187
193
|
throw e;
|
|
188
194
|
}
|
|
189
195
|
},
|
|
190
196
|
);
|
|
191
197
|
|
|
198
|
+
// EfB read-only seat mode stops here: tools/list is exactly search + read, and the write
|
|
199
|
+
// tools below don't exist for the seat at all (unavailable, not merely rejected).
|
|
200
|
+
if (readOnly) return server;
|
|
201
|
+
|
|
192
202
|
// ===========================================================================
|
|
193
|
-
// TOOL:
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
203
|
+
// TOOL: submit_company_candidate — POST /v2/company/river/stage
|
|
204
|
+
// The seat's ONE write: STAGE a user-approved item into the company Edda's Inbox. The seat
|
|
205
|
+
// never chooses the destination — it hands over one exact item (and may SUGGEST a folder);
|
|
206
|
+
// the company app (the River) classifies + places it on reconcile. Mirrors the personal
|
|
207
|
+
// side: everything lands in the Inbox first, then gets routed.
|
|
197
208
|
// ===========================================================================
|
|
198
209
|
server.tool(
|
|
199
|
-
"
|
|
200
|
-
"
|
|
201
|
-
"
|
|
202
|
-
"
|
|
203
|
-
"
|
|
204
|
-
"
|
|
205
|
-
"
|
|
206
|
-
"live (searchable now); a viewer's push lands in the company inbox for an admin to approve. " +
|
|
207
|
-
"Max 50 files. This is the SHARED company wiki — the user's own private files live on their disk.",
|
|
210
|
+
"submit_company_candidate",
|
|
211
|
+
"SHARE a file up into the COMPANY knowledge — stage one user-approved item into the company " +
|
|
212
|
+
"Edda's Inbox. You do NOT pick where it lands: the company routes it into the right folder " +
|
|
213
|
+
"itself (on the next reconcile). You may optionally `suggest` a destination as a hint. Use " +
|
|
214
|
+
"this to promote a finished note from your own work into the shared company brain. Only stage " +
|
|
215
|
+
"what the person has approved sharing — this leaves their private vault. (Their private files " +
|
|
216
|
+
"stay on their disk; this is the shared company layer.)",
|
|
208
217
|
{
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
+
name: z.string().describe("File/page name, e.g. 'MCP Overview EFB'. A '.md' suffix is added if missing."),
|
|
219
|
+
content: z.string().describe("The full markdown content of the item to share."),
|
|
220
|
+
suggested: z.string().optional().describe("Optional destination HINT, e.g. 'Projects/Data Centre/Working Documents' or 'General Edda'. The company still resolves/creates and places it — this only nudges."),
|
|
221
|
+
grade: z.enum(["fact", "summary", "full"]).optional().describe("How much of the source this carries: a single fact, a summary, or the full document."),
|
|
222
|
+
audience: z.enum(["company-wide", "department", "owner", "filing-agent-decides"]).optional().describe("The MOST this may be seen by (an upper bound the company may narrow, never broaden). Default: filing-agent-decides."),
|
|
223
|
+
idempotency_key: z.string().optional().describe("Optional stable key so re-submitting the same approved item files it at most once."),
|
|
224
|
+
},
|
|
225
|
+
async ({ name, content, suggested, grade, audience, idempotency_key }) => {
|
|
226
|
+
try {
|
|
227
|
+
const r = await post("/v2/company/river/stage", { name, content, suggested, grade, audience, idempotency_key });
|
|
228
|
+
const rc = r?.receipt ?? r;
|
|
229
|
+
if (rc?.idempotent) return text(`Already staged — "${name}" is in the company Inbox (${rc.location}). Nothing duplicated.`);
|
|
230
|
+
if (rc?.rejected === "hash_mismatch") return text(`Couldn't stage "${name}": the content didn't match its declared checksum (it may have changed after approval). Re-capture and try again.`);
|
|
231
|
+
if (rc?.staged === false) return text(`Couldn't stage "${name}": ${rc?.reason ?? "unknown error"}.`);
|
|
232
|
+
return text(`Staged "${name}" into the company Inbox (${rc?.location ?? "Inbox"}). The company will route it to the right folder on the next reconcile.`);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
const msg = String(e?.message || e);
|
|
235
|
+
if (msg.includes("admins_only") || msg.includes("(403)")) return text("You don't have a role that can share into this company wiki (members are read-only).");
|
|
236
|
+
if (msg.includes("name_required")) return text("Give the item a name and content to stage.");
|
|
237
|
+
throw e;
|
|
238
|
+
}
|
|
218
239
|
},
|
|
219
|
-
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// ===========================================================================
|
|
243
|
+
// TOOL: reconcile — POST /v2/company/river/reconcile
|
|
244
|
+
// Process the company Edda's Inbox: the River classifies + places each staged item into the
|
|
245
|
+
// right folder, embeds + links it. "Process the company inbox." Admin/ambassador only.
|
|
246
|
+
// ===========================================================================
|
|
247
|
+
server.tool(
|
|
248
|
+
"reconcile",
|
|
249
|
+
"PROCESS the company Inbox — have the company file every item waiting in its Inbox into the " +
|
|
250
|
+
"right folder (classify → place → make searchable). Run this after staging, or when the person " +
|
|
251
|
+
"says 'process the company inbox'. Optionally pass a single `stagingId` to file just that item. " +
|
|
252
|
+
"Returns where each item landed.",
|
|
253
|
+
{ stagingId: z.string().optional().describe("Optional id of one staged item to file (from a submit_company_candidate receipt). Omit to process the whole Inbox.") },
|
|
254
|
+
async ({ stagingId }) => {
|
|
220
255
|
try {
|
|
221
|
-
const r = await post("/v2/company/
|
|
222
|
-
const
|
|
223
|
-
|
|
256
|
+
const r = await post("/v2/company/river/reconcile", { stagingId });
|
|
257
|
+
const receipts = r?.receipts ?? [];
|
|
258
|
+
if (!receipts.length) return text("Nothing waiting in the company Inbox.");
|
|
259
|
+
const lines = receipts.map((x) => ` ${x.filed ? "✓ filed" : x.status} → ${x.location}${x.audience && x.audience !== "company" ? ` [${x.audience}]` : ""} (${x.reason})`);
|
|
260
|
+
return text(`Processed ${receipts.length} item(s) from the company Inbox:\n${lines.join("\n")}`);
|
|
224
261
|
} catch (e) {
|
|
225
262
|
const msg = String(e?.message || e);
|
|
226
|
-
if (msg.includes("
|
|
227
|
-
if (msg.includes("bad_folder")) return text("That folder path couldn't be resolved or created — check the name, or pass folder_id.");
|
|
228
|
-
if (msg.includes("too_many")) return text("Too many files — max 50 per call. Split into batches.");
|
|
229
|
-
if (msg.includes("pages_required")) return text("Give at least one file ({ name, content }).");
|
|
263
|
+
if (msg.includes("admins_only") || msg.includes("(403)")) return text("Only an admin or ambassador can process the company Inbox.");
|
|
230
264
|
throw e;
|
|
231
265
|
}
|
|
232
266
|
},
|
|
233
267
|
);
|
|
234
268
|
|
|
235
269
|
// ===========================================================================
|
|
236
|
-
// TOOL:
|
|
270
|
+
// TOOL: update — PATCH /v2/company/pages/:id
|
|
237
271
|
// Edit an existing wiki page: replace its content, or append. Re-embeds on the server.
|
|
238
272
|
// ===========================================================================
|
|
239
273
|
server.tool(
|
|
240
|
-
"
|
|
274
|
+
"update",
|
|
241
275
|
"Edit an EXISTING company wiki page — revise it or append to it. Identify the page by `path` " +
|
|
242
|
-
"(the `path:` from a
|
|
276
|
+
"(the `path:` from a search result) or `id`. By default the new `content` REPLACES the " +
|
|
243
277
|
"page; set append:true to add to the end instead (kept for a rolling log or a running doc). " +
|
|
244
|
-
"You can also rename it with `name`. Re-embedded so search picks up the change.
|
|
245
|
-
"use
|
|
278
|
+
"You can also rename it with `name`. Re-embedded so search picks up the change. To SHARE a NEW " +
|
|
279
|
+
"item into the company use submit_company_candidate. Distilled items (from sources) are read-only and can't be edited here.",
|
|
246
280
|
{
|
|
247
281
|
path: z.string().optional().describe("The page path from a search result's `path:` line. Provide this OR id."),
|
|
248
282
|
id: z.string().optional().describe("The page id (advanced). Provide this OR path."),
|
|
@@ -257,7 +291,7 @@ export function createServer() {
|
|
|
257
291
|
if (res.error) return text(res.error);
|
|
258
292
|
pageId = res.id;
|
|
259
293
|
}
|
|
260
|
-
if (!pageId) return text("Tell me which page to edit — pass the `path` from a
|
|
294
|
+
if (!pageId) return text("Tell me which page to edit — pass the `path` from a search result, or an `id`.");
|
|
261
295
|
try {
|
|
262
296
|
const body = {};
|
|
263
297
|
if (append) {
|
|
@@ -271,7 +305,7 @@ export function createServer() {
|
|
|
271
305
|
return text(`Updated "${name ? name.replace(/\.md$/, "") : (path ? String(path).split("/").pop().replace(/\.md$/, "") : pageId)}"${append ? " (appended)" : ""} — re-embedded, searchable now.`);
|
|
272
306
|
} catch (e) {
|
|
273
307
|
const msg = String(e?.message || e);
|
|
274
|
-
if (msg.includes("not_found") || msg.includes("(404)")) return text("No editable page with that id — distilled items are read-only. Use
|
|
308
|
+
if (msg.includes("not_found") || msg.includes("(404)")) return text("No editable page with that id — distilled items are read-only. Use search to find a wiki page.");
|
|
275
309
|
if (msg.includes("forbidden") || msg.includes("(403)")) return text("You don't have access to edit that company page.");
|
|
276
310
|
throw e;
|
|
277
311
|
}
|
|
@@ -279,18 +313,18 @@ export function createServer() {
|
|
|
279
313
|
);
|
|
280
314
|
|
|
281
315
|
// ===========================================================================
|
|
282
|
-
// TOOL:
|
|
316
|
+
// TOOL: create — POST /v2/company/folders
|
|
283
317
|
// Create a folder / nested path in the shared company tree so pages can be filed into it.
|
|
284
318
|
// ===========================================================================
|
|
285
319
|
server.tool(
|
|
286
|
-
"
|
|
320
|
+
"create",
|
|
287
321
|
"Create a folder in the COMPANY WIKI tree so you can file pages into it. The TOP-LEVEL folders " +
|
|
288
322
|
"are FIXED (Company · Projects · Decisions · Companies · People · Raw Documents · Archive) — you " +
|
|
289
323
|
"cannot add new ones. A bare name (or a path not starting with a fixed folder) becomes a PROJECT " +
|
|
290
324
|
"under Projects: 'Agency' → Projects/Agency. To nest under a specific top-level folder, start the " +
|
|
291
325
|
"path with it, e.g. 'Company/Policies' or 'Projects/Data Centre/Specs'. A write role is required; " +
|
|
292
|
-
"the folder appears immediately
|
|
293
|
-
"
|
|
326
|
+
"the folder appears immediately, and you can suggest it as a destination when you " +
|
|
327
|
+
"submit_company_candidate. Returns the folder id.",
|
|
294
328
|
{ path: z.string().describe("Folder name or path. A bare name → a project under Projects; 'Top-Level/Sub' nests under a fixed top-level folder.") },
|
|
295
329
|
async ({ path }) => {
|
|
296
330
|
try {
|