@notionhq/notion-mcp-server 1.9.0 → 2.0.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/.github/pull_request_template.md +8 -0
- package/.github/workflows/ci.yml +42 -0
- package/Dockerfile +0 -1
- package/README.md +133 -68
- package/bin/cli.mjs +133 -109
- package/package.json +6 -3
- package/scripts/notion-openapi.json +1699 -1399
- package/src/openapi-mcp-server/client/__tests__/http-client-upload.test.ts +4 -4
- package/src/openapi-mcp-server/client/__tests__/http-client.integration.test.ts +125 -16
- package/src/openapi-mcp-server/client/http-client.ts +8 -1
- package/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts +6 -4
- package/src/openapi-mcp-server/mcp/proxy.ts +1 -1
- package/src/openapi-mcp-server/openapi/parser.ts +7 -2
- package/smithery.yaml +0 -38
|
@@ -81,8 +81,8 @@ describe('HttpClient File Upload', () => {
|
|
|
81
81
|
const mockFormDataHeaders = { 'content-type': 'multipart/form-data; boundary=---123' }
|
|
82
82
|
|
|
83
83
|
vi.mocked(fs.createReadStream).mockReturnValue(mockFileStream as any)
|
|
84
|
-
vi.
|
|
85
|
-
vi.
|
|
84
|
+
vi.spyOn(FormData.prototype, 'append').mockImplementation(() => {})
|
|
85
|
+
vi.spyOn(FormData.prototype, 'getHeaders').mockReturnValue(mockFormDataHeaders)
|
|
86
86
|
|
|
87
87
|
const uploadPath = mockOpenApiSpec.paths['/upload']
|
|
88
88
|
if (!uploadPath?.post) {
|
|
@@ -135,8 +135,8 @@ describe('HttpClient File Upload', () => {
|
|
|
135
135
|
vi.mocked(fs.createReadStream)
|
|
136
136
|
.mockReturnValueOnce(mockFileStream1 as any)
|
|
137
137
|
.mockReturnValueOnce(mockFileStream2 as any)
|
|
138
|
-
vi.
|
|
139
|
-
vi.
|
|
138
|
+
vi.spyOn(FormData.prototype, 'append').mockImplementation(() => {})
|
|
139
|
+
vi.spyOn(FormData.prototype, 'getHeaders').mockReturnValue(mockFormDataHeaders)
|
|
140
140
|
|
|
141
141
|
const operation: OpenAPIV3.OperationObject = {
|
|
142
142
|
operationId: 'uploadFile',
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { describe, it, expect,
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { HttpClient } from '../http-client'
|
|
3
|
-
import
|
|
4
|
-
//@ts-ignore
|
|
5
|
-
import { createPetstoreServer } from '../../../examples/petstore-server.cjs'
|
|
3
|
+
import express from 'express'
|
|
6
4
|
import type { OpenAPIV3 } from 'openapi-types'
|
|
7
|
-
import
|
|
5
|
+
import type { Server } from 'http'
|
|
8
6
|
|
|
9
7
|
interface Pet {
|
|
10
8
|
id: number
|
|
@@ -14,20 +12,132 @@ interface Pet {
|
|
|
14
12
|
status: 'available' | 'pending' | 'sold'
|
|
15
13
|
}
|
|
16
14
|
|
|
15
|
+
// Simple in-memory pet store
|
|
16
|
+
let pets: Pet[] = []
|
|
17
|
+
let nextId = 1
|
|
18
|
+
|
|
19
|
+
// Initialize/reset pets data
|
|
20
|
+
function resetPets() {
|
|
21
|
+
pets = [
|
|
22
|
+
{ id: 1, name: 'Fluffy', species: 'Cat', age: 3, status: 'available' },
|
|
23
|
+
{ id: 2, name: 'Max', species: 'Dog', age: 5, status: 'available' },
|
|
24
|
+
{ id: 3, name: 'Tweety', species: 'Bird', age: 1, status: 'sold' },
|
|
25
|
+
]
|
|
26
|
+
nextId = 4
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createTestServer(port: number): Server {
|
|
30
|
+
const app = express()
|
|
31
|
+
app.use(express.json())
|
|
32
|
+
|
|
33
|
+
// GET /pets - List all pets
|
|
34
|
+
app.get('/pets', (req, res) => {
|
|
35
|
+
const status = req.query.status
|
|
36
|
+
const filtered = status ? pets.filter((p) => p.status === status) : pets
|
|
37
|
+
res.json(filtered)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// POST /pets - Create a pet
|
|
41
|
+
app.post('/pets', (req, res) => {
|
|
42
|
+
const newPet: Pet = {
|
|
43
|
+
id: nextId++,
|
|
44
|
+
name: req.body.name,
|
|
45
|
+
species: req.body.species,
|
|
46
|
+
age: req.body.age,
|
|
47
|
+
status: 'available',
|
|
48
|
+
}
|
|
49
|
+
pets.push(newPet)
|
|
50
|
+
res.status(201).json(newPet)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// GET /pets/:id - Get a pet by ID
|
|
54
|
+
app.get('/pets/:id', (req: any, res: any) => {
|
|
55
|
+
const pet = pets.find((p) => p.id === Number(req.params.id))
|
|
56
|
+
if (!pet) {
|
|
57
|
+
return res.status(404).json({ error: 'Pet not found' })
|
|
58
|
+
}
|
|
59
|
+
res.json(pet)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// PUT /pets/:id - Update a pet
|
|
63
|
+
app.put('/pets/:id', (req: any, res: any) => {
|
|
64
|
+
const pet = pets.find((p) => p.id === Number(req.params.id))
|
|
65
|
+
if (!pet) {
|
|
66
|
+
return res.status(404).json({ error: 'Pet not found' })
|
|
67
|
+
}
|
|
68
|
+
if (req.body.status) pet.status = req.body.status
|
|
69
|
+
res.json(pet)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// DELETE /pets/:id - Delete a pet
|
|
73
|
+
app.delete('/pets/:id', (req: any, res: any) => {
|
|
74
|
+
const index = pets.findIndex((p) => p.id === Number(req.params.id))
|
|
75
|
+
if (index === -1) {
|
|
76
|
+
return res.status(404).json({ error: 'Pet not found' })
|
|
77
|
+
}
|
|
78
|
+
pets.splice(index, 1)
|
|
79
|
+
res.status(204).send()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return app.listen(port)
|
|
83
|
+
}
|
|
84
|
+
|
|
17
85
|
describe('HttpClient Integration Tests', () => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
let server:
|
|
86
|
+
let PORT: number
|
|
87
|
+
let BASE_URL: string
|
|
88
|
+
let server: Server
|
|
21
89
|
let openApiSpec: OpenAPIV3.Document
|
|
22
90
|
let client: HttpClient
|
|
23
91
|
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
|
|
92
|
+
beforeEach(async () => {
|
|
93
|
+
// Use a random port to avoid conflicts
|
|
94
|
+
PORT = 3000 + Math.floor(Math.random() * 1000)
|
|
95
|
+
BASE_URL = `http://localhost:${PORT}`
|
|
27
96
|
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
97
|
+
// Initialize pets data
|
|
98
|
+
resetPets()
|
|
99
|
+
|
|
100
|
+
// Start the test server
|
|
101
|
+
server = createTestServer(PORT)
|
|
102
|
+
|
|
103
|
+
// Create a minimal OpenAPI spec for the test server
|
|
104
|
+
openApiSpec = {
|
|
105
|
+
openapi: '3.0.0',
|
|
106
|
+
info: { title: 'Pet Store API', version: '1.0.0' },
|
|
107
|
+
servers: [{ url: BASE_URL }],
|
|
108
|
+
paths: {
|
|
109
|
+
'/pets': {
|
|
110
|
+
get: {
|
|
111
|
+
operationId: 'listPets',
|
|
112
|
+
parameters: [{ name: 'status', in: 'query', schema: { type: 'string' } }],
|
|
113
|
+
responses: { '200': { description: 'Success' } },
|
|
114
|
+
},
|
|
115
|
+
post: {
|
|
116
|
+
operationId: 'createPet',
|
|
117
|
+
requestBody: { content: { 'application/json': { schema: { type: 'object' } } } },
|
|
118
|
+
responses: { '201': { description: 'Created' } },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
'/pets/{id}': {
|
|
122
|
+
get: {
|
|
123
|
+
operationId: 'getPet',
|
|
124
|
+
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
|
|
125
|
+
responses: { '200': { description: 'Success' }, '404': { description: 'Not found' } },
|
|
126
|
+
},
|
|
127
|
+
put: {
|
|
128
|
+
operationId: 'updatePet',
|
|
129
|
+
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
|
|
130
|
+
requestBody: { content: { 'application/json': { schema: { type: 'object' } } } },
|
|
131
|
+
responses: { '200': { description: 'Success' }, '404': { description: 'Not found' } },
|
|
132
|
+
},
|
|
133
|
+
delete: {
|
|
134
|
+
operationId: 'deletePet',
|
|
135
|
+
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
|
|
136
|
+
responses: { '204': { description: 'Deleted' }, '404': { description: 'Not found' } },
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
}
|
|
31
141
|
|
|
32
142
|
// Create HTTP client
|
|
33
143
|
client = new HttpClient(
|
|
@@ -41,8 +151,7 @@ describe('HttpClient Integration Tests', () => {
|
|
|
41
151
|
)
|
|
42
152
|
})
|
|
43
153
|
|
|
44
|
-
|
|
45
|
-
//@ts-expect-error
|
|
154
|
+
afterEach(async () => {
|
|
46
155
|
server.close()
|
|
47
156
|
})
|
|
48
157
|
|
|
@@ -177,7 +177,14 @@ export class HttpClient {
|
|
|
177
177
|
}
|
|
178
178
|
} catch (error: any) {
|
|
179
179
|
if (error.response) {
|
|
180
|
-
|
|
180
|
+
// Only log errors in non-test environments to keep test output clean
|
|
181
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
182
|
+
console.error('Error in http client', {
|
|
183
|
+
status: error.response.status,
|
|
184
|
+
statusText: error.response.statusText,
|
|
185
|
+
data: error.response.data,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
181
188
|
const headers = new Headers()
|
|
182
189
|
Object.entries(error.response.headers).forEach(([key, value]) => {
|
|
183
190
|
if (value) headers.append(key, value.toString())
|
|
@@ -231,7 +231,7 @@ describe('MCPProxy', () => {
|
|
|
231
231
|
})
|
|
232
232
|
|
|
233
233
|
it('should return empty object and warn on invalid JSON', () => {
|
|
234
|
-
const consoleSpy = vi.spyOn(console, 'warn')
|
|
234
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
235
235
|
process.env.OPENAPI_MCP_HEADERS = 'invalid json'
|
|
236
236
|
|
|
237
237
|
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
|
|
@@ -242,10 +242,11 @@ describe('MCPProxy', () => {
|
|
|
242
242
|
expect.anything(),
|
|
243
243
|
)
|
|
244
244
|
expect(consoleSpy).toHaveBeenCalledWith('Failed to parse OPENAPI_MCP_HEADERS environment variable:', expect.any(Error))
|
|
245
|
+
consoleSpy.mockRestore()
|
|
245
246
|
})
|
|
246
247
|
|
|
247
248
|
it('should return empty object and warn on non-object JSON', () => {
|
|
248
|
-
const consoleSpy = vi.spyOn(console, 'warn')
|
|
249
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
249
250
|
process.env.OPENAPI_MCP_HEADERS = '"string"'
|
|
250
251
|
|
|
251
252
|
const proxy = new MCPProxy('test-proxy', mockOpenApiSpec)
|
|
@@ -256,6 +257,7 @@ describe('MCPProxy', () => {
|
|
|
256
257
|
expect.anything(),
|
|
257
258
|
)
|
|
258
259
|
expect(consoleSpy).toHaveBeenCalledWith('OPENAPI_MCP_HEADERS environment variable must be a JSON object, got:', 'string')
|
|
260
|
+
consoleSpy.mockRestore()
|
|
259
261
|
})
|
|
260
262
|
|
|
261
263
|
it('should use NOTION_TOKEN when OPENAPI_MCP_HEADERS is not set', () => {
|
|
@@ -267,7 +269,7 @@ describe('MCPProxy', () => {
|
|
|
267
269
|
expect.objectContaining({
|
|
268
270
|
headers: {
|
|
269
271
|
'Authorization': 'Bearer ntn_test_token_123',
|
|
270
|
-
'Notion-Version': '
|
|
272
|
+
'Notion-Version': '2025-09-03'
|
|
271
273
|
},
|
|
272
274
|
}),
|
|
273
275
|
expect.anything(),
|
|
@@ -315,7 +317,7 @@ describe('MCPProxy', () => {
|
|
|
315
317
|
expect.objectContaining({
|
|
316
318
|
headers: {
|
|
317
319
|
'Authorization': 'Bearer ntn_test_token_123',
|
|
318
|
-
'Notion-Version': '
|
|
320
|
+
'Notion-Version': '2025-09-03'
|
|
319
321
|
},
|
|
320
322
|
}),
|
|
321
323
|
expect.anything(),
|
|
@@ -185,7 +185,8 @@ export class OpenAPIToMCPConverter {
|
|
|
185
185
|
if (mcpMethod) {
|
|
186
186
|
const uniqueName = this.ensureUniqueName(mcpMethod.name)
|
|
187
187
|
mcpMethod.name = uniqueName
|
|
188
|
-
|
|
188
|
+
// Apply description prefix to the already-built description (which includes error responses)
|
|
189
|
+
mcpMethod.description = this.getDescription(mcpMethod.description)
|
|
189
190
|
tools[apiName]!.methods.push(mcpMethod)
|
|
190
191
|
openApiLookup[apiName + '-' + uniqueName] = { ...operation, method, path }
|
|
191
192
|
zip[apiName + '-' + uniqueName] = { openApi: { ...operation, method, path }, mcp: mcpMethod }
|
|
@@ -519,6 +520,10 @@ export class OpenAPIToMCPConverter {
|
|
|
519
520
|
}
|
|
520
521
|
|
|
521
522
|
private getDescription(description: string): string {
|
|
522
|
-
|
|
523
|
+
// Only add "Notion | " prefix for the Notion API
|
|
524
|
+
if (this.openApiSpec.info.title === 'Notion API') {
|
|
525
|
+
return "Notion | " + description
|
|
526
|
+
}
|
|
527
|
+
return description
|
|
523
528
|
}
|
|
524
529
|
}
|
package/smithery.yaml
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# Smithery configuration file: https://smithery.ai/docs/build/project-config
|
|
2
|
-
|
|
3
|
-
startCommand:
|
|
4
|
-
type: stdio
|
|
5
|
-
commandFunction:
|
|
6
|
-
# A JS function that produces the CLI command based on the given config to start the MCP on stdio.
|
|
7
|
-
|-
|
|
8
|
-
(config) => {
|
|
9
|
-
const env = {};
|
|
10
|
-
if (config.notionToken) {
|
|
11
|
-
env.NOTION_TOKEN = config.notionToken;
|
|
12
|
-
} else if (config.openapiMcpHeaders) {
|
|
13
|
-
env.OPENAPI_MCP_HEADERS = config.openapiMcpHeaders;
|
|
14
|
-
}
|
|
15
|
-
if (config.baseUrl) env.BASE_URL = config.baseUrl;
|
|
16
|
-
return { command: 'notion-mcp-server', args: [], env };
|
|
17
|
-
}
|
|
18
|
-
configSchema:
|
|
19
|
-
# JSON Schema defining the configuration options for the MCP.
|
|
20
|
-
type: object
|
|
21
|
-
anyOf:
|
|
22
|
-
- required: [notionToken]
|
|
23
|
-
- required: [openapiMcpHeaders]
|
|
24
|
-
properties:
|
|
25
|
-
notionToken:
|
|
26
|
-
type: string
|
|
27
|
-
description: Notion integration token (recommended)
|
|
28
|
-
openapiMcpHeaders:
|
|
29
|
-
type: string
|
|
30
|
-
default: "{}"
|
|
31
|
-
description: JSON string for HTTP headers, must include Authorization and
|
|
32
|
-
Notion-Version (alternative to notionToken)
|
|
33
|
-
baseUrl:
|
|
34
|
-
type: string
|
|
35
|
-
description: Optional override for Notion API base URL
|
|
36
|
-
exampleConfig:
|
|
37
|
-
notionToken: 'ntn_abcdef'
|
|
38
|
-
baseUrl: https://api.notion.com
|