@fswap/mcp-outline 0.1.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/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/index.js +556 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gleb Okhrimenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @fswap/mcp-outline
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server for [Outline](https://www.getoutline.com) that lets Claude Desktop, Claude Code, Cursor and other MCP clients search, read, create and update your wiki.
|
|
4
|
+
|
|
5
|
+
Runs locally over stdio. No install step — clients launch it with `npx`.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
1. Create an API token in Outline under **Settings → API**.
|
|
10
|
+
2. Run the interactive setup once:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx -y @fswap/mcp-outline setup
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
It asks for your Outline URL and token, verifies them against `auth.info`, and stores them in your OS config directory (mode `0600`).
|
|
17
|
+
|
|
18
|
+
3. Add the server to your client. Every value asked in `setup` can be skipped with Enter; anything you skip goes into the `env` block shown below instead. `setup --print` shows these snippets again at any time.
|
|
19
|
+
|
|
20
|
+
**Claude Desktop** (`claude_desktop_config.json`) and **Cursor** (`~/.cursor/mcp.json` or `<project>/.cursor/mcp.json`):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"outline": {
|
|
26
|
+
"command": "npx",
|
|
27
|
+
"args": ["-y", "@fswap/mcp-outline"]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Codex** (`~/.codex/config.toml`):
|
|
34
|
+
|
|
35
|
+
```toml
|
|
36
|
+
[mcp_servers.outline]
|
|
37
|
+
command = "npx"
|
|
38
|
+
args = ["-y", "@fswap/mcp-outline"]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Claude Code**:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
claude mcp add outline -- npx -y @fswap/mcp-outline
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Without `setup` (environment variables)
|
|
48
|
+
|
|
49
|
+
Environment variables take precedence over the config file, so you can skip `setup` entirely (or skip individual values in it and set them here):
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"mcpServers": {
|
|
54
|
+
"outline": {
|
|
55
|
+
"command": "npx",
|
|
56
|
+
"args": ["-y", "@fswap/mcp-outline"],
|
|
57
|
+
"env": {
|
|
58
|
+
"OUTLINE_URL": "https://app.getoutline.com",
|
|
59
|
+
"OUTLINE_API_TOKEN": "ol_api_..."
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Codex equivalent:
|
|
67
|
+
|
|
68
|
+
```toml
|
|
69
|
+
[mcp_servers.outline]
|
|
70
|
+
command = "npx"
|
|
71
|
+
args = ["-y", "@fswap/mcp-outline"]
|
|
72
|
+
[mcp_servers.outline.env]
|
|
73
|
+
OUTLINE_URL = "https://app.getoutline.com"
|
|
74
|
+
OUTLINE_API_TOKEN = "ol_api_..."
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| Variable | Purpose |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `OUTLINE_URL` | Outline base URL (cloud or self-hosted) |
|
|
80
|
+
| `OUTLINE_API_TOKEN` | API token |
|
|
81
|
+
| `OUTLINE_ALLOW_DELETE` | `true` to expose `delete_document` |
|
|
82
|
+
| `OUTLINE_DEFAULT_COLLECTION` | Optional default collection name |
|
|
83
|
+
|
|
84
|
+
## Tools
|
|
85
|
+
|
|
86
|
+
| Tool | Outline endpoint | Notes |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `list_collections` | `collections.list` | id, name, description, url |
|
|
89
|
+
| `get_collection` | `collections.info` | includes document tree |
|
|
90
|
+
| `search_documents` | `documents.search` | query, optional `collectionId`; returns snippets, not bodies |
|
|
91
|
+
| `get_document` | `documents.info` | full markdown body; accepts id or URL slug |
|
|
92
|
+
| `list_documents` | `documents.list` | filter by `collectionId` / `parentDocumentId` |
|
|
93
|
+
| `create_document` | `documents.create` | title, markdown, collection, optional parent; published by default |
|
|
94
|
+
| `update_document` | `documents.update` | title/text; `append=true` appends instead of replacing |
|
|
95
|
+
| `move_document` | `documents.move` | change collection and/or parent |
|
|
96
|
+
| `archive_document` | `documents.archive` | reversible |
|
|
97
|
+
| `delete_document` | `documents.delete` | only when delete is allowed (setup answer or `OUTLINE_ALLOW_DELETE=true`) |
|
|
98
|
+
|
|
99
|
+
API errors are returned to the model as `isError` results rather than crashing the server.
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
TypeScript source in `src/`, bundled to `dist/` with [tsdown](https://tsdown.dev). Only `dist/` is published.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npm install
|
|
107
|
+
npm run build # tsdown → dist/index.js
|
|
108
|
+
npm run lint # eslint (typescript-eslint)
|
|
109
|
+
npm run typecheck # tsc --noEmit
|
|
110
|
+
npm test # builds, then spawns the server and checks the tool list
|
|
111
|
+
npm run check # all of the above (also runs on prepublishOnly)
|
|
112
|
+
OUTLINE_URL=... OUTLINE_API_TOKEN=... npm run inspect # MCP Inspector UI against dist/
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Never write to stdout from server code — it is the protocol channel. Use `console.error`.
|
|
116
|
+
|
|
117
|
+
## Reset
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npx -y @fswap/mcp-outline setup --reset
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import envPaths from "env-paths";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import * as p from "@clack/prompts";
|
|
10
|
+
//#region \0rolldown/runtime.js
|
|
11
|
+
var __defProp = Object.defineProperty;
|
|
12
|
+
var __esmMin = (fn, res, err) => () => {
|
|
13
|
+
if (err) throw err[0];
|
|
14
|
+
try {
|
|
15
|
+
return fn && (res = fn(fn = 0)), res;
|
|
16
|
+
} catch (e) {
|
|
17
|
+
throw err = [e], e;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
var __exportAll = (all, no_symbols) => {
|
|
21
|
+
let target = {};
|
|
22
|
+
for (var name in all) __defProp(target, name, {
|
|
23
|
+
get: all[name],
|
|
24
|
+
enumerable: true
|
|
25
|
+
});
|
|
26
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
27
|
+
return target;
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/config.ts
|
|
31
|
+
function configPath() {
|
|
32
|
+
return path.join(paths.config, "config.json");
|
|
33
|
+
}
|
|
34
|
+
function truthy(v) {
|
|
35
|
+
return v != null && /^(1|true|yes|on)$/i.test(v.trim());
|
|
36
|
+
}
|
|
37
|
+
function readConfigFile() {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(fs.readFileSync(configPath(), "utf8"));
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err.code === "ENOENT") return null;
|
|
42
|
+
console.error(`Warning: could not read ${configPath()}: ${err.message}`);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolve runtime configuration.
|
|
48
|
+
* Precedence: environment variables > config file written by `setup`.
|
|
49
|
+
* Returns null when neither provides url + token.
|
|
50
|
+
*/
|
|
51
|
+
function loadConfig() {
|
|
52
|
+
const env = process.env;
|
|
53
|
+
const file = readConfigFile() ?? {};
|
|
54
|
+
const url = env.OUTLINE_URL || file.url;
|
|
55
|
+
const token = env.OUTLINE_API_TOKEN || file.token;
|
|
56
|
+
if (!url || !token) return null;
|
|
57
|
+
const deleteEnv = env.OUTLINE_ALLOW_DELETE ?? env.ALLOW_DELETE;
|
|
58
|
+
const allowDelete = deleteEnv != null ? truthy(deleteEnv) : Boolean(file.allowDelete);
|
|
59
|
+
return {
|
|
60
|
+
url: url.replace(/\/+$/, ""),
|
|
61
|
+
token,
|
|
62
|
+
allowDelete,
|
|
63
|
+
defaultCollection: env.OUTLINE_DEFAULT_COLLECTION || file.defaultCollection || ""
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function saveConfig(config) {
|
|
67
|
+
const file = configPath();
|
|
68
|
+
fs.mkdirSync(path.dirname(file), {
|
|
69
|
+
recursive: true,
|
|
70
|
+
mode: 448
|
|
71
|
+
});
|
|
72
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
|
|
73
|
+
fs.chmodSync(file, 384);
|
|
74
|
+
return file;
|
|
75
|
+
}
|
|
76
|
+
function deleteConfig() {
|
|
77
|
+
try {
|
|
78
|
+
fs.unlinkSync(configPath());
|
|
79
|
+
return true;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err.code === "ENOENT") return false;
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
var pkg, PACKAGE_NAME, VERSION, paths;
|
|
86
|
+
var init_config = __esmMin((() => {
|
|
87
|
+
pkg = createRequire(import.meta.url)("../package.json");
|
|
88
|
+
PACKAGE_NAME = pkg.name;
|
|
89
|
+
VERSION = pkg.version;
|
|
90
|
+
paths = envPaths("mcp-outline", { suffix: "" });
|
|
91
|
+
}));
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/client.ts
|
|
94
|
+
function makeClient(baseUrl, token) {
|
|
95
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
96
|
+
async function post(endpoint, body = {}) {
|
|
97
|
+
const url = `${base}/api/${endpoint}`;
|
|
98
|
+
let res;
|
|
99
|
+
try {
|
|
100
|
+
res = await fetch(url, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {
|
|
103
|
+
Authorization: `Bearer ${token}`,
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
Accept: "application/json"
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify(body)
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw new Error(`POST ${endpoint} failed: ${err.message}`);
|
|
111
|
+
}
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
const text = await res.text();
|
|
114
|
+
let detail = text;
|
|
115
|
+
try {
|
|
116
|
+
const json = JSON.parse(text);
|
|
117
|
+
detail = json.message || json.error || text;
|
|
118
|
+
} catch {}
|
|
119
|
+
throw new Error(`POST ${endpoint} → ${res.status}: ${detail}`);
|
|
120
|
+
}
|
|
121
|
+
return await res.json();
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
post,
|
|
125
|
+
baseUrl: base
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Cheap authenticated call used by `setup` to validate URL + token. */
|
|
129
|
+
async function authInfo(baseUrl, token) {
|
|
130
|
+
const { post } = makeClient(baseUrl, token);
|
|
131
|
+
const { data } = await post("auth.info");
|
|
132
|
+
return data;
|
|
133
|
+
}
|
|
134
|
+
var init_client = __esmMin((() => {}));
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/tools/_shared.ts
|
|
137
|
+
init_config();
|
|
138
|
+
init_client();
|
|
139
|
+
function ok(value) {
|
|
140
|
+
return { content: [{
|
|
141
|
+
type: "text",
|
|
142
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
143
|
+
}] };
|
|
144
|
+
}
|
|
145
|
+
function fail(err) {
|
|
146
|
+
return {
|
|
147
|
+
isError: true,
|
|
148
|
+
content: [{
|
|
149
|
+
type: "text",
|
|
150
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
151
|
+
}]
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Wrap a tool handler so API errors become isError results instead of crashes. */
|
|
155
|
+
function guard(fn) {
|
|
156
|
+
return async (args) => {
|
|
157
|
+
try {
|
|
158
|
+
return await fn(args);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return fail(err);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function stripUndefined(obj) {
|
|
165
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
|
|
166
|
+
}
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/tools/collections.ts
|
|
169
|
+
function registerCollectionTools(server, outline) {
|
|
170
|
+
server.registerTool("list_collections", {
|
|
171
|
+
title: "List collections",
|
|
172
|
+
description: "List Outline collections (top-level groupings of documents). Returns id, name, description and url. Use the id with search_documents, list_documents or create_document.",
|
|
173
|
+
inputSchema: {
|
|
174
|
+
query: z.string().optional().describe("Filter collections by name (substring match)"),
|
|
175
|
+
limit: z.number().int().min(1).max(100).default(25).describe("Max collections to return")
|
|
176
|
+
}
|
|
177
|
+
}, guard(async ({ query, limit }) => {
|
|
178
|
+
const body = {
|
|
179
|
+
limit,
|
|
180
|
+
sort: "name",
|
|
181
|
+
direction: "ASC"
|
|
182
|
+
};
|
|
183
|
+
if (query) body.query = query;
|
|
184
|
+
const { data } = await outline.post("collections.list", body);
|
|
185
|
+
return ok(data.map((c) => ({
|
|
186
|
+
id: c.id,
|
|
187
|
+
name: c.name,
|
|
188
|
+
description: c.description || null,
|
|
189
|
+
url: c.url
|
|
190
|
+
})));
|
|
191
|
+
}));
|
|
192
|
+
server.registerTool("get_collection", {
|
|
193
|
+
title: "Get collection",
|
|
194
|
+
description: "Get one Outline collection including its document tree (ids, titles and nesting). Useful to understand how a collection is organised before creating or moving documents.",
|
|
195
|
+
inputSchema: { id: z.string().describe("Collection id") }
|
|
196
|
+
}, guard(async ({ id }) => {
|
|
197
|
+
const { data } = await outline.post("collections.info", { id });
|
|
198
|
+
return ok({
|
|
199
|
+
id: data.id,
|
|
200
|
+
name: data.name,
|
|
201
|
+
description: data.description || null,
|
|
202
|
+
url: data.url,
|
|
203
|
+
documents: data.documents ?? []
|
|
204
|
+
});
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/tools/documents.ts
|
|
209
|
+
function summarize(d) {
|
|
210
|
+
return {
|
|
211
|
+
id: d.id,
|
|
212
|
+
title: d.title,
|
|
213
|
+
url: d.url,
|
|
214
|
+
urlId: d.urlId,
|
|
215
|
+
collectionId: d.collectionId,
|
|
216
|
+
parentDocumentId: d.parentDocumentId ?? null,
|
|
217
|
+
updatedAt: d.updatedAt,
|
|
218
|
+
publishedAt: d.publishedAt ?? null,
|
|
219
|
+
archivedAt: d.archivedAt ?? null
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function registerDocumentTools(server, outline, { allowDelete = false } = {}) {
|
|
223
|
+
server.registerTool("search_documents", {
|
|
224
|
+
title: "Search documents",
|
|
225
|
+
description: "Full-text search across Outline documents. Returns id, title, url and a short context snippet per hit. Does NOT return document bodies — call get_document with an id to read one.",
|
|
226
|
+
inputSchema: {
|
|
227
|
+
query: z.string().min(1).describe("Search terms"),
|
|
228
|
+
collectionId: z.string().optional().describe("Restrict results to one collection"),
|
|
229
|
+
limit: z.number().int().min(1).max(50).default(10).describe("Max results"),
|
|
230
|
+
offset: z.number().int().min(0).default(0).describe("Pagination offset")
|
|
231
|
+
}
|
|
232
|
+
}, guard(async ({ query, collectionId, limit, offset }) => {
|
|
233
|
+
const { data } = await outline.post("documents.search", stripUndefined({
|
|
234
|
+
query,
|
|
235
|
+
collectionId,
|
|
236
|
+
limit,
|
|
237
|
+
offset
|
|
238
|
+
}));
|
|
239
|
+
return ok(data.map((r) => ({
|
|
240
|
+
id: r.document.id,
|
|
241
|
+
title: r.document.title,
|
|
242
|
+
url: r.document.url,
|
|
243
|
+
collectionId: r.document.collectionId,
|
|
244
|
+
updatedAt: r.document.updatedAt,
|
|
245
|
+
snippet: r.context,
|
|
246
|
+
ranking: r.ranking
|
|
247
|
+
})));
|
|
248
|
+
}));
|
|
249
|
+
server.registerTool("get_document", {
|
|
250
|
+
title: "Get document",
|
|
251
|
+
description: "Fetch a single Outline document including its full markdown body. Accepts a document id, a urlId (the slug part of the URL) or a share id.",
|
|
252
|
+
inputSchema: { id: z.string().describe("Document id, urlId or share id") }
|
|
253
|
+
}, guard(async ({ id }) => {
|
|
254
|
+
const { data } = await outline.post("documents.info", { id });
|
|
255
|
+
return ok({
|
|
256
|
+
...summarize(data),
|
|
257
|
+
text: data.text ?? ""
|
|
258
|
+
});
|
|
259
|
+
}));
|
|
260
|
+
server.registerTool("list_documents", {
|
|
261
|
+
title: "List documents",
|
|
262
|
+
description: "List documents, optionally filtered by collection and/or parent document. Returns id, title, url and timestamps (no bodies). Sorted by most recently updated by default.",
|
|
263
|
+
inputSchema: {
|
|
264
|
+
collectionId: z.string().optional().describe("Only documents in this collection"),
|
|
265
|
+
parentDocumentId: z.string().optional().describe("Only direct children of this document"),
|
|
266
|
+
sort: z.enum([
|
|
267
|
+
"updatedAt",
|
|
268
|
+
"createdAt",
|
|
269
|
+
"title",
|
|
270
|
+
"index"
|
|
271
|
+
]).default("updatedAt"),
|
|
272
|
+
direction: z.enum(["ASC", "DESC"]).default("DESC"),
|
|
273
|
+
limit: z.number().int().min(1).max(100).default(25),
|
|
274
|
+
offset: z.number().int().min(0).default(0)
|
|
275
|
+
}
|
|
276
|
+
}, guard(async ({ collectionId, parentDocumentId, sort, direction, limit, offset }) => {
|
|
277
|
+
const { data } = await outline.post("documents.list", stripUndefined({
|
|
278
|
+
collectionId,
|
|
279
|
+
parentDocumentId,
|
|
280
|
+
sort,
|
|
281
|
+
direction,
|
|
282
|
+
limit,
|
|
283
|
+
offset
|
|
284
|
+
}));
|
|
285
|
+
return ok(data.map(summarize));
|
|
286
|
+
}));
|
|
287
|
+
server.registerTool("create_document", {
|
|
288
|
+
title: "Create document",
|
|
289
|
+
description: "Create a new Outline document from markdown. Published by default; pass publish=false to save a draft. Requires a collectionId (use list_collections). Optionally nest under a parent document.",
|
|
290
|
+
inputSchema: {
|
|
291
|
+
title: z.string().min(1).describe("Document title"),
|
|
292
|
+
text: z.string().default("").describe("Document body in markdown"),
|
|
293
|
+
collectionId: z.string().describe("Collection to create the document in"),
|
|
294
|
+
parentDocumentId: z.string().optional().describe("Nest under this document"),
|
|
295
|
+
publish: z.boolean().default(true).describe("Publish immediately (false = draft)")
|
|
296
|
+
}
|
|
297
|
+
}, guard(async ({ title, text, collectionId, parentDocumentId, publish }) => {
|
|
298
|
+
const { data } = await outline.post("documents.create", stripUndefined({
|
|
299
|
+
title,
|
|
300
|
+
text,
|
|
301
|
+
collectionId,
|
|
302
|
+
parentDocumentId,
|
|
303
|
+
publish
|
|
304
|
+
}));
|
|
305
|
+
return ok(summarize(data));
|
|
306
|
+
}));
|
|
307
|
+
server.registerTool("update_document", {
|
|
308
|
+
title: "Update document",
|
|
309
|
+
description: "Update an existing document's title and/or markdown body. By default `text` REPLACES the body; set append=true to add `text` to the end instead. Only the fields you pass are changed.",
|
|
310
|
+
inputSchema: {
|
|
311
|
+
id: z.string().describe("Document id"),
|
|
312
|
+
title: z.string().optional().describe("New title"),
|
|
313
|
+
text: z.string().optional().describe("New markdown body (or text to append when append=true)"),
|
|
314
|
+
append: z.boolean().default(false).describe("Append `text` to the existing body instead of replacing it"),
|
|
315
|
+
publish: z.boolean().optional().describe("Publish a draft as part of this update"),
|
|
316
|
+
done: z.boolean().optional().describe("Mark the editing session as finished (triggers notifications)")
|
|
317
|
+
}
|
|
318
|
+
}, guard(async ({ id, title, text, append, publish, done }) => {
|
|
319
|
+
if (title === void 0 && text === void 0 && publish === void 0) throw new Error("Nothing to update: pass title, text or publish.");
|
|
320
|
+
const { data } = await outline.post("documents.update", stripUndefined({
|
|
321
|
+
id,
|
|
322
|
+
title,
|
|
323
|
+
text,
|
|
324
|
+
append: text !== void 0 ? append : void 0,
|
|
325
|
+
publish,
|
|
326
|
+
done
|
|
327
|
+
}));
|
|
328
|
+
return ok(summarize(data));
|
|
329
|
+
}));
|
|
330
|
+
server.registerTool("move_document", {
|
|
331
|
+
title: "Move document",
|
|
332
|
+
description: "Move a document to another collection and/or under a different parent document. Omit parentDocumentId to move it to the top level of the collection.",
|
|
333
|
+
inputSchema: {
|
|
334
|
+
id: z.string().describe("Document id"),
|
|
335
|
+
collectionId: z.string().optional().describe("Target collection id"),
|
|
336
|
+
parentDocumentId: z.string().optional().describe("Target parent document id"),
|
|
337
|
+
index: z.number().int().min(0).optional().describe("Position among siblings")
|
|
338
|
+
}
|
|
339
|
+
}, guard(async ({ id, collectionId, parentDocumentId, index }) => {
|
|
340
|
+
if (!collectionId && !parentDocumentId) throw new Error("Pass collectionId and/or parentDocumentId.");
|
|
341
|
+
const { data } = await outline.post("documents.move", stripUndefined({
|
|
342
|
+
id,
|
|
343
|
+
collectionId,
|
|
344
|
+
parentDocumentId,
|
|
345
|
+
index
|
|
346
|
+
}));
|
|
347
|
+
return ok({ documents: (data.documents ?? []).map(summarize) });
|
|
348
|
+
}));
|
|
349
|
+
server.registerTool("archive_document", {
|
|
350
|
+
title: "Archive document",
|
|
351
|
+
description: "Archive a document (reversible; it disappears from the collection but can be restored from the archive). Prefer this over delete_document.",
|
|
352
|
+
inputSchema: { id: z.string().describe("Document id") }
|
|
353
|
+
}, guard(async ({ id }) => {
|
|
354
|
+
const { data } = await outline.post("documents.archive", { id });
|
|
355
|
+
return ok(summarize(data));
|
|
356
|
+
}));
|
|
357
|
+
if (allowDelete) server.registerTool("delete_document", {
|
|
358
|
+
title: "Delete document",
|
|
359
|
+
description: "Move a document to the trash. Documents in the trash are permanently deleted after 30 days. Only use when the user explicitly asks to delete; archive_document is the safer alternative.",
|
|
360
|
+
inputSchema: {
|
|
361
|
+
id: z.string().describe("Document id"),
|
|
362
|
+
permanent: z.boolean().default(false).describe("Permanently delete instead of trashing (irreversible)")
|
|
363
|
+
}
|
|
364
|
+
}, guard(async ({ id, permanent }) => {
|
|
365
|
+
await outline.post("documents.delete", {
|
|
366
|
+
id,
|
|
367
|
+
permanent
|
|
368
|
+
});
|
|
369
|
+
return ok({
|
|
370
|
+
deleted: id,
|
|
371
|
+
permanent
|
|
372
|
+
});
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/snippets.ts
|
|
377
|
+
function jsonSnippet(missing) {
|
|
378
|
+
const server = {
|
|
379
|
+
command: "npx",
|
|
380
|
+
args: ["-y", PACKAGE_NAME]
|
|
381
|
+
};
|
|
382
|
+
if (Object.keys(missing).length > 0) server.env = missing;
|
|
383
|
+
return JSON.stringify({ mcpServers: { [SERVER_KEY]: server } }, null, 2);
|
|
384
|
+
}
|
|
385
|
+
function tomlSnippet(missing) {
|
|
386
|
+
const lines = [
|
|
387
|
+
`[mcp_servers.${SERVER_KEY}]`,
|
|
388
|
+
"command = \"npx\"",
|
|
389
|
+
`args = ["-y", "${PACKAGE_NAME}"]`
|
|
390
|
+
];
|
|
391
|
+
if (Object.keys(missing).length > 0) {
|
|
392
|
+
lines.push(`[mcp_servers.${SERVER_KEY}.env]`);
|
|
393
|
+
for (const [k, v] of Object.entries(missing)) lines.push(`${k} = "${v}"`);
|
|
394
|
+
}
|
|
395
|
+
return lines.join("\n");
|
|
396
|
+
}
|
|
397
|
+
function claudeCodeSnippet(missing) {
|
|
398
|
+
const env = Object.entries(missing).map(([k, v]) => `-e ${k}=${v} `).join("");
|
|
399
|
+
return `claude mcp add ${SERVER_KEY} ${env}-- npx -y ${PACKAGE_NAME}`;
|
|
400
|
+
}
|
|
401
|
+
function clientSnippets(missing = {}) {
|
|
402
|
+
return [
|
|
403
|
+
{
|
|
404
|
+
title: "Claude Desktop — claude_desktop_config.json",
|
|
405
|
+
body: jsonSnippet(missing)
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
title: "Cursor — ~/.cursor/mcp.json (or <project>/.cursor/mcp.json)",
|
|
409
|
+
body: jsonSnippet(missing)
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
title: "Codex — ~/.codex/config.toml",
|
|
413
|
+
body: tomlSnippet(missing)
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
title: "Claude Code",
|
|
417
|
+
body: claudeCodeSnippet(missing)
|
|
418
|
+
}
|
|
419
|
+
];
|
|
420
|
+
}
|
|
421
|
+
var SERVER_KEY, ENV_URL, ENV_TOKEN;
|
|
422
|
+
var init_snippets = __esmMin((() => {
|
|
423
|
+
SERVER_KEY = "outline";
|
|
424
|
+
ENV_URL = "OUTLINE_URL";
|
|
425
|
+
ENV_TOKEN = "OUTLINE_API_TOKEN";
|
|
426
|
+
}));
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region src/setup.ts
|
|
429
|
+
var setup_exports = /* @__PURE__ */ __exportAll({ runSetup: () => runSetup });
|
|
430
|
+
function abort() {
|
|
431
|
+
p.cancel("Setup aborted.");
|
|
432
|
+
process.exit(1);
|
|
433
|
+
}
|
|
434
|
+
function printSnippets(missing) {
|
|
435
|
+
for (const s of clientSnippets(missing)) p.note(s.body, s.title);
|
|
436
|
+
}
|
|
437
|
+
async function runSetup(args = []) {
|
|
438
|
+
if (args.includes("--reset")) {
|
|
439
|
+
const removed = deleteConfig();
|
|
440
|
+
console.log(removed ? `Removed ${configPath()}` : `Nothing to remove (${configPath()} does not exist)`);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const existing = readConfigFile() ?? {};
|
|
444
|
+
if (args.includes("--print")) {
|
|
445
|
+
const missing = {};
|
|
446
|
+
if (!existing.url) missing[ENV_URL] = "https://app.getoutline.com";
|
|
447
|
+
if (!existing.token) missing[ENV_TOKEN] = "ol_api_...";
|
|
448
|
+
printSnippets(missing);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
p.intro(`${PACKAGE_NAME} setup`);
|
|
452
|
+
p.log.info("Press Enter to skip any value. Skipped values can be supplied later via the env block of your MCP client config.");
|
|
453
|
+
const answers = await p.group({
|
|
454
|
+
url: () => p.text({
|
|
455
|
+
message: `Outline URL (${ENV_URL})`,
|
|
456
|
+
placeholder: "https://app.getoutline.com — Enter to skip",
|
|
457
|
+
initialValue: existing.url ?? "",
|
|
458
|
+
validate: (v) => !v || /^https?:\/\/\S+$/.test(v) ? void 0 : "Must start with http:// or https://"
|
|
459
|
+
}),
|
|
460
|
+
token: () => p.password({
|
|
461
|
+
message: `API token (${ENV_TOKEN}; Settings → API in Outline) — Enter to skip`,
|
|
462
|
+
mask: "▪"
|
|
463
|
+
}),
|
|
464
|
+
defaultCollection: () => p.text({
|
|
465
|
+
message: "Default collection name (optional)",
|
|
466
|
+
initialValue: existing.defaultCollection ?? "",
|
|
467
|
+
defaultValue: ""
|
|
468
|
+
}),
|
|
469
|
+
allowDelete: () => p.confirm({
|
|
470
|
+
message: "Allow delete tools?",
|
|
471
|
+
initialValue: Boolean(existing.allowDelete)
|
|
472
|
+
})
|
|
473
|
+
}, { onCancel: abort });
|
|
474
|
+
const url = (answers.url ?? "").trim().replace(/\/+$/, "") || void 0;
|
|
475
|
+
const token = (answers.token ?? "").trim() || existing.token || void 0;
|
|
476
|
+
if (url && token) {
|
|
477
|
+
const s = p.spinner();
|
|
478
|
+
s.start("Checking connection");
|
|
479
|
+
try {
|
|
480
|
+
const me = await authInfo(url, token);
|
|
481
|
+
const who = me.user?.email ?? me.user?.name ?? "unknown user";
|
|
482
|
+
s.stop(`ok (logged in as ${who} on ${me.team?.name ?? url})`);
|
|
483
|
+
} catch (err) {
|
|
484
|
+
s.stop("failed");
|
|
485
|
+
p.cancel(`Could not authenticate: ${err.message}`);
|
|
486
|
+
process.exit(1);
|
|
487
|
+
}
|
|
488
|
+
} else p.log.warn("Skipping connection check because URL and/or token were not provided.");
|
|
489
|
+
const config = {
|
|
490
|
+
defaultCollection: (answers.defaultCollection ?? "").trim(),
|
|
491
|
+
allowDelete: Boolean(answers.allowDelete)
|
|
492
|
+
};
|
|
493
|
+
if (url) config.url = url;
|
|
494
|
+
if (token) config.token = token;
|
|
495
|
+
const file = saveConfig(config);
|
|
496
|
+
const missing = {};
|
|
497
|
+
if (!url) missing[ENV_URL] = "https://app.getoutline.com";
|
|
498
|
+
if (!token) missing[ENV_TOKEN] = "ol_api_...";
|
|
499
|
+
if (Object.keys(missing).length > 0) p.log.warn(`Still needed: ${Object.keys(missing).join(", ")}. Add them to the env block below, or run setup again.`);
|
|
500
|
+
printSnippets(missing);
|
|
501
|
+
p.outro(`Saved to ${file}`);
|
|
502
|
+
}
|
|
503
|
+
var init_setup = __esmMin((() => {
|
|
504
|
+
init_config();
|
|
505
|
+
init_snippets();
|
|
506
|
+
}));
|
|
507
|
+
//#endregion
|
|
508
|
+
//#region src/index.ts
|
|
509
|
+
init_config();
|
|
510
|
+
init_client();
|
|
511
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
512
|
+
if (cmd === "setup") {
|
|
513
|
+
const { runSetup } = await Promise.resolve().then(() => (init_setup(), setup_exports));
|
|
514
|
+
await runSetup(rest);
|
|
515
|
+
} else if (cmd === "--help" || cmd === "-h" || cmd === "help") printHelp();
|
|
516
|
+
else if (cmd === "--version" || cmd === "-v") console.log(VERSION);
|
|
517
|
+
else await runServer();
|
|
518
|
+
function printHelp() {
|
|
519
|
+
console.log(`${PACKAGE_NAME} ${VERSION}
|
|
520
|
+
|
|
521
|
+
Usage:
|
|
522
|
+
npx ${PACKAGE_NAME} start the MCP server (stdio)
|
|
523
|
+
npx ${PACKAGE_NAME} setup interactive configuration
|
|
524
|
+
npx ${PACKAGE_NAME} setup --reset delete stored configuration
|
|
525
|
+
npx ${PACKAGE_NAME} setup --print print MCP client config snippets
|
|
526
|
+
|
|
527
|
+
Configuration precedence:
|
|
528
|
+
1. OUTLINE_URL, OUTLINE_API_TOKEN, OUTLINE_ALLOW_DELETE env vars
|
|
529
|
+
2. ${configPath()}
|
|
530
|
+
`);
|
|
531
|
+
}
|
|
532
|
+
async function runServer() {
|
|
533
|
+
const config = loadConfig();
|
|
534
|
+
if (!config) {
|
|
535
|
+
console.error(`No configuration found. Run: npx ${PACKAGE_NAME} setup`);
|
|
536
|
+
console.error("(or set OUTLINE_URL and OUTLINE_API_TOKEN in the environment)");
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
if (process.stdin.isTTY) {
|
|
540
|
+
console.error(`${PACKAGE_NAME} is an MCP stdio server and expects to be launched by an MCP client.`);
|
|
541
|
+
console.error(`Run "npx ${PACKAGE_NAME} setup" to configure it, or "npx ${PACKAGE_NAME} --help".`);
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
const outline = makeClient(config.url, config.token);
|
|
545
|
+
const server = new McpServer({
|
|
546
|
+
name: "mcp-outline",
|
|
547
|
+
version: VERSION
|
|
548
|
+
});
|
|
549
|
+
registerCollectionTools(server, outline);
|
|
550
|
+
registerDocumentTools(server, outline, { allowDelete: config.allowDelete });
|
|
551
|
+
const transport = new StdioServerTransport();
|
|
552
|
+
await server.connect(transport);
|
|
553
|
+
console.error(`mcp-outline ${VERSION} connected (${config.url}, delete tools ${config.allowDelete ? "enabled" : "disabled"})`);
|
|
554
|
+
}
|
|
555
|
+
//#endregion
|
|
556
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fswap/mcp-outline",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Outline (getoutline.com) — search, read, create and update wiki documents from Claude",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mcp-outline": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsdown",
|
|
17
|
+
"dev": "tsdown --watch",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"lint": "eslint src test",
|
|
20
|
+
"test": "npm run build && node test/smoke.test.mjs",
|
|
21
|
+
"check": "npm run lint && npm run typecheck && npm test",
|
|
22
|
+
"start": "node dist/index.js",
|
|
23
|
+
"setup": "node dist/index.js setup",
|
|
24
|
+
"inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
|
|
25
|
+
"prepublishOnly": "npm run check"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@clack/prompts": "^1.0.0",
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
30
|
+
"env-paths": "^4.0.0",
|
|
31
|
+
"zod": "^4.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
35
|
+
"eslint": "^10.0.0",
|
|
36
|
+
"tsdown": "^0.23.0",
|
|
37
|
+
"typescript": "^5.9.0",
|
|
38
|
+
"typescript-eslint": "^8.70.0"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"mcp",
|
|
42
|
+
"outline",
|
|
43
|
+
"getoutline",
|
|
44
|
+
"wiki",
|
|
45
|
+
"claude",
|
|
46
|
+
"modelcontextprotocol"
|
|
47
|
+
],
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/sunblob/mcp-outline.git"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/sunblob/mcp-outline#readme",
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/sunblob/mcp-outline/issues"
|
|
56
|
+
}
|
|
57
|
+
}
|