@satiyap/confluence-reader-mcp 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 +214 -0
- package/dist/compare/diff.js +122 -0
- package/dist/confluence/client.js +47 -0
- package/dist/confluence/transform.js +35 -0
- package/dist/confluence/types.js +1 -0
- package/dist/confluence/url.js +32 -0
- package/dist/index.js +85 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 satiyap
|
|
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,214 @@
|
|
|
1
|
+
# Confluence Reader MCP Server
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@satiyap/confluence-reader-mcp)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://www.typescriptlang.org/)
|
|
6
|
+
[](https://nodejs.org/)
|
|
7
|
+
|
|
8
|
+
MCP server for fetching and comparing Confluence documentation with local files. Enables AI assistants to read Confluence pages and generate git-style diffs against local documentation.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- **URL-based fetching**: Pass any Confluence page URL, automatically extracts page ID
|
|
13
|
+
- **Clean text extraction**: Converts Confluence storage HTML to readable text/markdown
|
|
14
|
+
- **Git-style diffs**: Generate unified diffs comparing Confluence docs with local documentation
|
|
15
|
+
- **Flexible auth**: Supports scoped API tokens with Bearer authentication
|
|
16
|
+
- **Dual routing**: Works with cloudId routing or direct baseUrl
|
|
17
|
+
- **Zero install**: Use via `npx` for frictionless setup
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
### 1. Set Environment Variables
|
|
22
|
+
|
|
23
|
+
Get your scoped API token from: https://support.atlassian.com/confluence/kb/scoped-api-tokens-in-confluence-cloud/
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
export CONFLUENCE_TOKEN="your_scoped_token_here"
|
|
27
|
+
export CONFLUENCE_CLOUD_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or copy `.env.example` to `.env` and fill in your values.
|
|
31
|
+
|
|
32
|
+
### 2. Install Dependencies & Build
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install
|
|
36
|
+
npm run build
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 3. Configure MCP
|
|
40
|
+
|
|
41
|
+
Add to your MCP settings configuration file (e.g., `mcp.json` or similar):
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"confluence-reader": {
|
|
47
|
+
"command": "npx",
|
|
48
|
+
"args": ["@satiyap/confluence-reader-mcp"],
|
|
49
|
+
"env": {
|
|
50
|
+
"CONFLUENCE_TOKEN": "${env:CONFLUENCE_TOKEN}",
|
|
51
|
+
"CONFLUENCE_CLOUD_ID": "${env:CONFLUENCE_CLOUD_ID}"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 4. Restart Your MCP Host
|
|
59
|
+
|
|
60
|
+
Restart your MCP-compatible application to load the server.
|
|
61
|
+
|
|
62
|
+
## Environment Variables
|
|
63
|
+
|
|
64
|
+
| Variable | Required | Description |
|
|
65
|
+
|----------|----------|-------------|
|
|
66
|
+
| `CONFLUENCE_TOKEN` | Yes | [Scoped API token](https://support.atlassian.com/confluence/kb/scoped-api-tokens-in-confluence-cloud/) (Bearer auth only) |
|
|
67
|
+
| `CONFLUENCE_CLOUD_ID` | Recommended | Atlassian Cloud ID for api.atlassian.com routing |
|
|
68
|
+
| `CONFLUENCE_BASE_URL` | Optional | Fallback: `https://yourtenant.atlassian.net` |
|
|
69
|
+
|
|
70
|
+
**Authentication:**
|
|
71
|
+
- Only supports scoped API tokens with Bearer authentication
|
|
72
|
+
- No email/password or Basic auth support
|
|
73
|
+
|
|
74
|
+
**Routing:**
|
|
75
|
+
- If `CONFLUENCE_CLOUD_ID` is set, uses `https://api.atlassian.com/ex/confluence/{cloudId}`
|
|
76
|
+
- Otherwise uses `CONFLUENCE_BASE_URL`
|
|
77
|
+
|
|
78
|
+
## Available Tools
|
|
79
|
+
|
|
80
|
+
### `confluence.fetch_doc`
|
|
81
|
+
|
|
82
|
+
Fetch a Confluence Cloud page by URL, returning clean text for analysis.
|
|
83
|
+
|
|
84
|
+
**Parameters:**
|
|
85
|
+
- `url` (string, required): Confluence page URL
|
|
86
|
+
- Supports: `/wiki/spaces/KEY/pages/123456789/Title`
|
|
87
|
+
- Supports: `/wiki/pages/viewpage.action?pageId=123456789`
|
|
88
|
+
- `includeStorageHtml` (boolean, optional): If true, also returns original storage HTML
|
|
89
|
+
|
|
90
|
+
**Returns:**
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"pageId": "123456789",
|
|
94
|
+
"title": "Page Title",
|
|
95
|
+
"status": "current",
|
|
96
|
+
"version": 42,
|
|
97
|
+
"webui": "/wiki/spaces/...",
|
|
98
|
+
"extractedText": "Clean text content...",
|
|
99
|
+
"storageHtml": "..." // if includeStorageHtml=true
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `docs.build_comparison_bundle`
|
|
104
|
+
|
|
105
|
+
Build a git-style unified diff comparing local documentation against Confluence content.
|
|
106
|
+
|
|
107
|
+
**Parameters:**
|
|
108
|
+
- `confluenceText` (string, required): Text from `confluence.fetch_doc.extractedText`
|
|
109
|
+
- `prd` (string, optional): Local document text (e.g., PRD, requirements)
|
|
110
|
+
- `systemOverview` (string, optional): Local document text (e.g., architecture overview)
|
|
111
|
+
- `systemDesign` (string, optional): Local document text (e.g., technical design)
|
|
112
|
+
- `lld` (string, optional): Local document text (e.g., detailed design, implementation notes)
|
|
113
|
+
|
|
114
|
+
**Note:** Parameter names are flexible - use them for any type of documentation you want to compare.
|
|
115
|
+
|
|
116
|
+
**Returns:**
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"totalComparisons": 2,
|
|
120
|
+
"diffs": [
|
|
121
|
+
{
|
|
122
|
+
"document": "PRD",
|
|
123
|
+
"additions": 15,
|
|
124
|
+
"deletions": 8,
|
|
125
|
+
"totalChanges": 23,
|
|
126
|
+
"diff": "--- a/confluence\n+++ b/prd\n@@ -1,5 +1,5 @@\n context line\n-removed line\n+added line\n context line"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"document": "System Design",
|
|
130
|
+
"additions": 42,
|
|
131
|
+
"deletions": 12,
|
|
132
|
+
"totalChanges": 54,
|
|
133
|
+
"diff": "..."
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Usage Example
|
|
140
|
+
|
|
141
|
+
When a user provides a Confluence URL in their prompt:
|
|
142
|
+
|
|
143
|
+
1. AI assistant detects the URL
|
|
144
|
+
2. Calls `confluence.fetch_doc` with the URL
|
|
145
|
+
3. Calls `docs.build_comparison_bundle` with:
|
|
146
|
+
- `confluenceText` from step 2
|
|
147
|
+
- Local documentation content from filesystem
|
|
148
|
+
4. AI assistant analyzes the structured comparison and reports differences
|
|
149
|
+
|
|
150
|
+
## Supported Confluence URL Formats
|
|
151
|
+
|
|
152
|
+
- `/wiki/spaces/SPACEKEY/pages/123456789/Page+Title`
|
|
153
|
+
- `/wiki/pages/viewpage.action?pageId=123456789`
|
|
154
|
+
- Any URL containing `/pages/<numeric-id>/`
|
|
155
|
+
|
|
156
|
+
## Project Structure
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
confluence-reader-mcp/
|
|
160
|
+
├── src/
|
|
161
|
+
│ ├── index.ts # MCP server + tool registrations
|
|
162
|
+
│ ├── confluence/
|
|
163
|
+
│ │ ├── client.ts # HTTP client with scoped token auth
|
|
164
|
+
│ │ ├── url.ts # URL → pageId parser
|
|
165
|
+
│ │ ├── types.ts # API response types
|
|
166
|
+
│ │ └── transform.ts # Storage HTML → text converter
|
|
167
|
+
│ └── compare/
|
|
168
|
+
│ └── diff.ts # Git-style unified diff generator
|
|
169
|
+
├── dist/ # Compiled output
|
|
170
|
+
├── package.json # Binary: confluence-reader-mcp
|
|
171
|
+
├── tsconfig.json
|
|
172
|
+
├── .env.example
|
|
173
|
+
├── .gitignore
|
|
174
|
+
└── README.md
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
npm run dev # Run with tsx (no build needed)
|
|
181
|
+
npm run build # Compile TypeScript
|
|
182
|
+
npm start # Run compiled server
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Security Notes
|
|
186
|
+
|
|
187
|
+
- Never commit tokens to git (see `.gitignore`)
|
|
188
|
+
- Use scoped API tokens with minimal permissions
|
|
189
|
+
- Tokens are read from OS environment only
|
|
190
|
+
- No tokens in config files
|
|
191
|
+
|
|
192
|
+
## Publishing to npm (Optional)
|
|
193
|
+
|
|
194
|
+
Once ready for public use:
|
|
195
|
+
|
|
196
|
+
1. Build the package:
|
|
197
|
+
```bash
|
|
198
|
+
npm run build
|
|
199
|
+
```
|
|
200
|
+
2. Publish to npm:
|
|
201
|
+
```bash
|
|
202
|
+
npm publish --access public
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## API References
|
|
206
|
+
|
|
207
|
+
- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
|
|
208
|
+
- [Model Context Protocol](https://modelcontextprotocol.io/)
|
|
209
|
+
- [Confluence REST API v2](https://developer.atlassian.com/cloud/confluence/rest/v2/)
|
|
210
|
+
- [Atlassian Scoped API Tokens](https://support.atlassian.com/confluence/kb/scoped-api-tokens-in-confluence-cloud/)
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a git-style unified diff between two texts
|
|
3
|
+
* This is a simple line-based diff implementation
|
|
4
|
+
*/
|
|
5
|
+
function longestCommonSubsequence(a, b) {
|
|
6
|
+
const m = a.length;
|
|
7
|
+
const n = b.length;
|
|
8
|
+
const dp = Array(m + 1).fill(0).map(() => Array(n + 1).fill(0));
|
|
9
|
+
for (let i = 1; i <= m; i++) {
|
|
10
|
+
for (let j = 1; j <= n; j++) {
|
|
11
|
+
if (a[i - 1] === b[j - 1]) {
|
|
12
|
+
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return dp;
|
|
20
|
+
}
|
|
21
|
+
function buildDiff(a, b, dp) {
|
|
22
|
+
const result = [];
|
|
23
|
+
let i = a.length;
|
|
24
|
+
let j = b.length;
|
|
25
|
+
while (i > 0 || j > 0) {
|
|
26
|
+
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
|
27
|
+
result.unshift({ type: 'context', line: a[i - 1], oldLineNum: i, newLineNum: j });
|
|
28
|
+
i--;
|
|
29
|
+
j--;
|
|
30
|
+
}
|
|
31
|
+
else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
32
|
+
result.unshift({ type: 'add', line: b[j - 1], newLineNum: j });
|
|
33
|
+
j--;
|
|
34
|
+
}
|
|
35
|
+
else if (i > 0) {
|
|
36
|
+
result.unshift({ type: 'remove', line: a[i - 1], oldLineNum: i });
|
|
37
|
+
i--;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
function formatUnifiedDiff(oldLabel, newLabel, diffLines, contextLines = 3) {
|
|
43
|
+
if (diffLines.length === 0) {
|
|
44
|
+
return `--- ${oldLabel}\n+++ ${newLabel}\n(no differences)\n`;
|
|
45
|
+
}
|
|
46
|
+
const output = [];
|
|
47
|
+
output.push(`--- ${oldLabel}`);
|
|
48
|
+
output.push(`+++ ${newLabel}`);
|
|
49
|
+
// Group changes into hunks
|
|
50
|
+
const hunks = [];
|
|
51
|
+
let currentHunk = [];
|
|
52
|
+
let lastChangeIndex = -1;
|
|
53
|
+
diffLines.forEach((line, idx) => {
|
|
54
|
+
const isChange = line.type !== 'context';
|
|
55
|
+
if (isChange) {
|
|
56
|
+
// Include context before and after
|
|
57
|
+
const start = Math.max(0, lastChangeIndex + 1, idx - contextLines);
|
|
58
|
+
const contextBefore = diffLines.slice(start, idx).filter(l => !currentHunk.includes(l));
|
|
59
|
+
currentHunk.push(...contextBefore, line);
|
|
60
|
+
lastChangeIndex = idx;
|
|
61
|
+
}
|
|
62
|
+
else if (lastChangeIndex >= 0 && idx - lastChangeIndex <= contextLines) {
|
|
63
|
+
// Context after a change
|
|
64
|
+
currentHunk.push(line);
|
|
65
|
+
}
|
|
66
|
+
else if (lastChangeIndex >= 0 && idx - lastChangeIndex > contextLines) {
|
|
67
|
+
// End current hunk
|
|
68
|
+
if (currentHunk.length > 0) {
|
|
69
|
+
hunks.push(currentHunk);
|
|
70
|
+
currentHunk = [];
|
|
71
|
+
}
|
|
72
|
+
lastChangeIndex = -1;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
if (currentHunk.length > 0) {
|
|
76
|
+
hunks.push(currentHunk);
|
|
77
|
+
}
|
|
78
|
+
// Format each hunk
|
|
79
|
+
hunks.forEach(hunk => {
|
|
80
|
+
const firstLine = hunk[0];
|
|
81
|
+
const lastLine = hunk[hunk.length - 1];
|
|
82
|
+
const oldStart = firstLine.oldLineNum || 1;
|
|
83
|
+
const newStart = firstLine.newLineNum || 1;
|
|
84
|
+
const oldCount = hunk.filter(l => l.type !== 'add').length;
|
|
85
|
+
const newCount = hunk.filter(l => l.type !== 'remove').length;
|
|
86
|
+
output.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
|
|
87
|
+
hunk.forEach(line => {
|
|
88
|
+
switch (line.type) {
|
|
89
|
+
case 'context':
|
|
90
|
+
output.push(` ${line.line}`);
|
|
91
|
+
break;
|
|
92
|
+
case 'add':
|
|
93
|
+
output.push(`+${line.line}`);
|
|
94
|
+
break;
|
|
95
|
+
case 'remove':
|
|
96
|
+
output.push(`-${line.line}`);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
return output.join('\n');
|
|
102
|
+
}
|
|
103
|
+
export function generateUnifiedDiff(oldText, newText, oldLabel = 'a/original', newLabel = 'b/modified') {
|
|
104
|
+
const oldLines = oldText.split('\n');
|
|
105
|
+
const newLines = newText.split('\n');
|
|
106
|
+
const dp = longestCommonSubsequence(oldLines, newLines);
|
|
107
|
+
const diffLines = buildDiff(oldLines, newLines, dp);
|
|
108
|
+
return formatUnifiedDiff(oldLabel, newLabel, diffLines);
|
|
109
|
+
}
|
|
110
|
+
export function generateDiffStats(oldText, newText) {
|
|
111
|
+
const oldLines = oldText.split('\n');
|
|
112
|
+
const newLines = newText.split('\n');
|
|
113
|
+
const dp = longestCommonSubsequence(oldLines, newLines);
|
|
114
|
+
const diffLines = buildDiff(oldLines, newLines, dp);
|
|
115
|
+
const additions = diffLines.filter(l => l.type === 'add').length;
|
|
116
|
+
const deletions = diffLines.filter(l => l.type === 'remove').length;
|
|
117
|
+
return {
|
|
118
|
+
additions,
|
|
119
|
+
deletions,
|
|
120
|
+
changes: additions + deletions
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build authorization headers for Confluence API requests
|
|
3
|
+
* Only supports scoped API tokens with Bearer authentication
|
|
4
|
+
*
|
|
5
|
+
* @see https://support.atlassian.com/confluence/kb/scoped-api-tokens-in-confluence-cloud/
|
|
6
|
+
*/
|
|
7
|
+
function buildAuthHeaders(cfg) {
|
|
8
|
+
return { Authorization: `Bearer ${cfg.token}` };
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Build base URL for Confluence API requests
|
|
12
|
+
* Prefers cloudId routing over direct baseUrl
|
|
13
|
+
*/
|
|
14
|
+
function buildBase(cfg) {
|
|
15
|
+
// Prefer cloudId routing (works well with scoped token access patterns)
|
|
16
|
+
if (cfg.cloudId)
|
|
17
|
+
return `https://api.atlassian.com/ex/confluence/${cfg.cloudId}`;
|
|
18
|
+
if (cfg.baseUrl)
|
|
19
|
+
return cfg.baseUrl;
|
|
20
|
+
throw new Error("Set CONFLUENCE_CLOUD_ID or CONFLUENCE_BASE_URL.");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Fetch a Confluence page by ID using the v2 REST API
|
|
24
|
+
*
|
|
25
|
+
* @param cfg - Client configuration with token and routing info
|
|
26
|
+
* @param pageId - Numeric page ID
|
|
27
|
+
* @returns Page data including title, content, and metadata
|
|
28
|
+
* @throws Error if API request fails
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchPageById(cfg, pageId) {
|
|
31
|
+
const base = buildBase(cfg);
|
|
32
|
+
// v2 endpoint with body-format=storage to get HTML content
|
|
33
|
+
const url = new URL(`${base}/wiki/api/v2/pages/${pageId}`);
|
|
34
|
+
url.searchParams.set("body-format", "storage");
|
|
35
|
+
const res = await fetch(url.toString(), {
|
|
36
|
+
method: "GET",
|
|
37
|
+
headers: {
|
|
38
|
+
...buildAuthHeaders(cfg),
|
|
39
|
+
Accept: "application/json"
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
const text = await res.text().catch(() => "");
|
|
44
|
+
throw new Error(`Confluence API error ${res.status}: ${text.slice(0, 500)}`);
|
|
45
|
+
}
|
|
46
|
+
return (await res.json());
|
|
47
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert Confluence storage HTML to plain text
|
|
3
|
+
*
|
|
4
|
+
* This is a lightweight HTML-to-text converter that:
|
|
5
|
+
* - Strips HTML tags
|
|
6
|
+
* - Preserves paragraph and heading breaks
|
|
7
|
+
* - Decodes common HTML entities
|
|
8
|
+
*
|
|
9
|
+
* Note: Not a perfect HTML→Markdown converter; intentionally simple for MCP use.
|
|
10
|
+
*
|
|
11
|
+
* @param storageHtml - Confluence storage format HTML
|
|
12
|
+
* @returns Plain text representation
|
|
13
|
+
*/
|
|
14
|
+
export function storageToText(storageHtml) {
|
|
15
|
+
// Minimal, safe-ish conversion:
|
|
16
|
+
// - strip tags
|
|
17
|
+
// - preserve headings/paragraph-ish breaks
|
|
18
|
+
// Not a perfect HTML->MD converter; intentionally lightweight for an MCP tool.
|
|
19
|
+
const withBreaks = storageHtml
|
|
20
|
+
.replace(/<\/(p|h1|h2|h3|h4|li|tr|div)>/gi, "\n")
|
|
21
|
+
.replace(/<br\s*\/?>/gi, "\n");
|
|
22
|
+
const stripped = withBreaks.replace(/<[^>]+>/g, "");
|
|
23
|
+
const decoded = stripped
|
|
24
|
+
.replace(/ /g, " ")
|
|
25
|
+
.replace(/&/g, "&")
|
|
26
|
+
.replace(/</g, "<")
|
|
27
|
+
.replace(/>/g, ">")
|
|
28
|
+
.replace(/"/g, "\"")
|
|
29
|
+
.replace(/'/g, "'");
|
|
30
|
+
return decoded
|
|
31
|
+
.split("\n")
|
|
32
|
+
.map((l) => l.trim())
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.join("\n");
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract Confluence page ID from various URL formats
|
|
3
|
+
*
|
|
4
|
+
* Supported formats:
|
|
5
|
+
* - /wiki/spaces/KEY/pages/123456789/Title
|
|
6
|
+
* - /wiki/pages/viewpage.action?pageId=123456789
|
|
7
|
+
*
|
|
8
|
+
* @param url - Full Confluence page URL
|
|
9
|
+
* @returns Page ID as string
|
|
10
|
+
* @throws Error if URL format is not recognized
|
|
11
|
+
*/
|
|
12
|
+
export function extractConfluencePageId(url) {
|
|
13
|
+
// Common Confluence Cloud patterns:
|
|
14
|
+
// 1) /wiki/spaces/KEY/pages/123456789/Title
|
|
15
|
+
// 2) /wiki/pages/viewpage.action?pageId=123456789
|
|
16
|
+
// 3) Some short links redirect, but the final URL usually matches one of the above.
|
|
17
|
+
try {
|
|
18
|
+
const u = new URL(url);
|
|
19
|
+
// Pattern 2: viewpage.action?pageId=...
|
|
20
|
+
const pageId = u.searchParams.get("pageId");
|
|
21
|
+
if (pageId && /^\d+$/.test(pageId))
|
|
22
|
+
return pageId;
|
|
23
|
+
// Pattern 1: .../pages/<id>/...
|
|
24
|
+
const m = u.pathname.match(/\/pages\/(\d+)(\/|$)/);
|
|
25
|
+
if (m?.[1])
|
|
26
|
+
return m[1];
|
|
27
|
+
throw new Error("Unsupported Confluence URL format (no pageId found).");
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
throw new Error(`Invalid URL: ${e.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { extractConfluencePageId } from "./confluence/url.js";
|
|
6
|
+
import { fetchPageById } from "./confluence/client.js";
|
|
7
|
+
import { storageToText } from "./confluence/transform.js";
|
|
8
|
+
import { generateUnifiedDiff, generateDiffStats } from "./compare/diff.js";
|
|
9
|
+
const server = new McpServer({
|
|
10
|
+
name: "confluence-reader-mcp",
|
|
11
|
+
version: "0.1.0"
|
|
12
|
+
});
|
|
13
|
+
function getEnv(name) {
|
|
14
|
+
const v = process.env[name];
|
|
15
|
+
return v && v.trim().length > 0 ? v.trim() : undefined;
|
|
16
|
+
}
|
|
17
|
+
server.tool("confluence.fetch_doc", "Fetch a Confluence Cloud page by URL using env-scoped credentials, returning clean text for analysis.", {
|
|
18
|
+
url: z.string().describe("Confluence page URL (e.g. /wiki/spaces/.../pages/<id>/...)"),
|
|
19
|
+
includeStorageHtml: z.boolean().optional().describe("If true, also return original storage HTML")
|
|
20
|
+
}, async ({ url, includeStorageHtml }) => {
|
|
21
|
+
const token = getEnv("CONFLUENCE_TOKEN");
|
|
22
|
+
const cloudId = getEnv("CONFLUENCE_CLOUD_ID");
|
|
23
|
+
const baseUrl = getEnv("CONFLUENCE_BASE_URL");
|
|
24
|
+
if (!token)
|
|
25
|
+
throw new Error("Missing CONFLUENCE_TOKEN env var (scoped API token required).");
|
|
26
|
+
const pageId = extractConfluencePageId(url);
|
|
27
|
+
const page = await fetchPageById({ token, cloudId, baseUrl }, pageId);
|
|
28
|
+
const storage = page.body?.storage?.value ?? "";
|
|
29
|
+
const text = storage ? storageToText(storage) : "";
|
|
30
|
+
const payload = {
|
|
31
|
+
pageId: page.id,
|
|
32
|
+
title: page.title,
|
|
33
|
+
status: page.status,
|
|
34
|
+
version: page.version?.number,
|
|
35
|
+
webui: page._links?.webui,
|
|
36
|
+
extractedText: text,
|
|
37
|
+
...(includeStorageHtml ? { storageHtml: storage } : {})
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
// Helper tool to generate git-style diffs between Confluence and local docs
|
|
44
|
+
server.tool("docs.build_comparison_bundle", "Build a git-style unified diff comparing PRD/System Overview/System Design/LLD against a Confluence page text.", {
|
|
45
|
+
confluenceText: z.string().describe("Text extracted from Confluence (output of confluence.fetch_doc.extractedText)"),
|
|
46
|
+
prd: z.string().optional().describe("Local PRD text"),
|
|
47
|
+
systemOverview: z.string().optional().describe("Local System Overview text"),
|
|
48
|
+
systemDesign: z.string().optional().describe("Local System Design text"),
|
|
49
|
+
lld: z.string().optional().describe("Local LLD text")
|
|
50
|
+
}, async (args) => {
|
|
51
|
+
const diffs = [];
|
|
52
|
+
const sections = [
|
|
53
|
+
["PRD", args.prd],
|
|
54
|
+
["System Overview", args.systemOverview],
|
|
55
|
+
["System Design", args.systemDesign],
|
|
56
|
+
["LLD", args.lld]
|
|
57
|
+
].filter(([, v]) => !!v && v.trim().length > 0);
|
|
58
|
+
for (const [name, localText] of sections) {
|
|
59
|
+
const docName = name;
|
|
60
|
+
const diff = generateUnifiedDiff(args.confluenceText.trim(), localText.trim(), `a/confluence`, `b/${docName.toLowerCase().replace(/\s+/g, '-')}`);
|
|
61
|
+
const stats = generateDiffStats(args.confluenceText.trim(), localText.trim());
|
|
62
|
+
diffs.push({ name: docName, diff, stats });
|
|
63
|
+
}
|
|
64
|
+
const summary = {
|
|
65
|
+
totalComparisons: diffs.length,
|
|
66
|
+
diffs: diffs.map(d => ({
|
|
67
|
+
document: d.name,
|
|
68
|
+
additions: d.stats.additions,
|
|
69
|
+
deletions: d.stats.deletions,
|
|
70
|
+
totalChanges: d.stats.changes,
|
|
71
|
+
diff: d.diff
|
|
72
|
+
}))
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: "text", text: JSON.stringify(summary, null, 2) }]
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
async function main() {
|
|
79
|
+
const transport = new StdioServerTransport();
|
|
80
|
+
await server.connect(transport);
|
|
81
|
+
}
|
|
82
|
+
main().catch((err) => {
|
|
83
|
+
console.error("MCP server failed:", err);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@satiyap/confluence-reader-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for fetching and comparing Confluence documentation with local files",
|
|
5
|
+
"author": "satiyap",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"mcp",
|
|
9
|
+
"confluence",
|
|
10
|
+
"documentation",
|
|
11
|
+
"model-context-protocol"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/satiyap/confluence-reader-mcp.git"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"main": "dist/index.js",
|
|
19
|
+
"bin": {
|
|
20
|
+
"confluence-reader-mcp": "dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"dev": "tsx src/index.ts",
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"start": "node dist/index.js",
|
|
31
|
+
"prepublishOnly": "npm run build"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
35
|
+
"zod": "^3.25.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.0.0",
|
|
39
|
+
"tsx": "^4.0.0",
|
|
40
|
+
"typescript": "^5.0.0"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|