@gwdn/nexus-mcp 0.7.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.
Files changed (133) hide show
  1. package/.github/workflows/ci.yml +38 -0
  2. package/.github/workflows/release.yml +57 -0
  3. package/README.md +89 -0
  4. package/dist/auth.d.ts +39 -0
  5. package/dist/auth.d.ts.map +1 -0
  6. package/dist/auth.js +47 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/nexus-api.d.ts +35 -0
  9. package/dist/nexus-api.d.ts.map +1 -0
  10. package/dist/nexus-api.js +90 -0
  11. package/dist/nexus-api.js.map +1 -0
  12. package/dist/server.d.ts +67 -0
  13. package/dist/server.d.ts.map +1 -0
  14. package/dist/server.js +189 -0
  15. package/dist/server.js.map +1 -0
  16. package/dist/tools/add-task-note.d.ts +34 -0
  17. package/dist/tools/add-task-note.d.ts.map +1 -0
  18. package/dist/tools/add-task-note.js +39 -0
  19. package/dist/tools/add-task-note.js.map +1 -0
  20. package/dist/tools/append-session-entry.d.ts +53 -0
  21. package/dist/tools/append-session-entry.d.ts.map +1 -0
  22. package/dist/tools/append-session-entry.js +68 -0
  23. package/dist/tools/append-session-entry.js.map +1 -0
  24. package/dist/tools/create-task.d.ts +52 -0
  25. package/dist/tools/create-task.d.ts.map +1 -0
  26. package/dist/tools/create-task.js +51 -0
  27. package/dist/tools/create-task.js.map +1 -0
  28. package/dist/tools/decision-comments.d.ts +54 -0
  29. package/dist/tools/decision-comments.d.ts.map +1 -0
  30. package/dist/tools/decision-comments.js +81 -0
  31. package/dist/tools/decision-comments.js.map +1 -0
  32. package/dist/tools/get-document.d.ts +47 -0
  33. package/dist/tools/get-document.d.ts.map +1 -0
  34. package/dist/tools/get-document.js +68 -0
  35. package/dist/tools/get-document.js.map +1 -0
  36. package/dist/tools/get-project-memory.d.ts +47 -0
  37. package/dist/tools/get-project-memory.d.ts.map +1 -0
  38. package/dist/tools/get-project-memory.js +53 -0
  39. package/dist/tools/get-project-memory.js.map +1 -0
  40. package/dist/tools/get-related-entities.d.ts +44 -0
  41. package/dist/tools/get-related-entities.d.ts.map +1 -0
  42. package/dist/tools/get-related-entities.js +60 -0
  43. package/dist/tools/get-related-entities.js.map +1 -0
  44. package/dist/tools/governance.d.ts +90 -0
  45. package/dist/tools/governance.d.ts.map +1 -0
  46. package/dist/tools/governance.js +127 -0
  47. package/dist/tools/governance.js.map +1 -0
  48. package/dist/tools/ingest-document.d.ts +40 -0
  49. package/dist/tools/ingest-document.d.ts.map +1 -0
  50. package/dist/tools/ingest-document.js +49 -0
  51. package/dist/tools/ingest-document.js.map +1 -0
  52. package/dist/tools/letter-inbox.d.ts +80 -0
  53. package/dist/tools/letter-inbox.d.ts.map +1 -0
  54. package/dist/tools/letter-inbox.js +118 -0
  55. package/dist/tools/letter-inbox.js.map +1 -0
  56. package/dist/tools/letters.d.ts +91 -0
  57. package/dist/tools/letters.d.ts.map +1 -0
  58. package/dist/tools/letters.js +112 -0
  59. package/dist/tools/letters.js.map +1 -0
  60. package/dist/tools/list-documents.d.ts +33 -0
  61. package/dist/tools/list-documents.d.ts.map +1 -0
  62. package/dist/tools/list-documents.js +47 -0
  63. package/dist/tools/list-documents.js.map +1 -0
  64. package/dist/tools/list-tasks.d.ts +39 -0
  65. package/dist/tools/list-tasks.d.ts.map +1 -0
  66. package/dist/tools/list-tasks.js +46 -0
  67. package/dist/tools/list-tasks.js.map +1 -0
  68. package/dist/tools/project-list.d.ts +28 -0
  69. package/dist/tools/project-list.d.ts.map +1 -0
  70. package/dist/tools/project-list.js +43 -0
  71. package/dist/tools/project-list.js.map +1 -0
  72. package/dist/tools/reviews.d.ts +145 -0
  73. package/dist/tools/reviews.d.ts.map +1 -0
  74. package/dist/tools/reviews.js +220 -0
  75. package/dist/tools/reviews.js.map +1 -0
  76. package/dist/tools/search-knowledge.d.ts +48 -0
  77. package/dist/tools/search-knowledge.d.ts.map +1 -0
  78. package/dist/tools/search-knowledge.js +54 -0
  79. package/dist/tools/search-knowledge.js.map +1 -0
  80. package/dist/tools/sessions.d.ts +81 -0
  81. package/dist/tools/sessions.d.ts.map +1 -0
  82. package/dist/tools/sessions.js +123 -0
  83. package/dist/tools/sessions.js.map +1 -0
  84. package/dist/tools/skill-assign.d.ts +77 -0
  85. package/dist/tools/skill-assign.d.ts.map +1 -0
  86. package/dist/tools/skill-assign.js +108 -0
  87. package/dist/tools/skill-assign.js.map +1 -0
  88. package/dist/tools/skills.d.ts +138 -0
  89. package/dist/tools/skills.d.ts.map +1 -0
  90. package/dist/tools/skills.js +195 -0
  91. package/dist/tools/skills.js.map +1 -0
  92. package/dist/tools/update-task-status.d.ts +48 -0
  93. package/dist/tools/update-task-status.d.ts.map +1 -0
  94. package/dist/tools/update-task-status.js +51 -0
  95. package/dist/tools/update-task-status.js.map +1 -0
  96. package/package.json +30 -0
  97. package/src/__tests__/auth.test.ts +162 -0
  98. package/src/__tests__/decision-comments.test.ts +173 -0
  99. package/src/__tests__/e2e-integration.test.ts +443 -0
  100. package/src/__tests__/helpers.ts +58 -0
  101. package/src/__tests__/layer1-knowledge.test.ts +302 -0
  102. package/src/__tests__/layer2-coordination.test.ts +842 -0
  103. package/src/__tests__/layer3-governance.test.ts +205 -0
  104. package/src/__tests__/project-list-and-skill-assign.test.ts +282 -0
  105. package/src/__tests__/reviews.test.ts +420 -0
  106. package/src/__tests__/server.test.ts +250 -0
  107. package/src/__tests__/setup.ts +15 -0
  108. package/src/__tests__/task-list-and-doc-list.test.ts +226 -0
  109. package/src/auth.ts +81 -0
  110. package/src/nexus-api.ts +127 -0
  111. package/src/server.ts +520 -0
  112. package/src/tools/add-task-note.ts +50 -0
  113. package/src/tools/append-session-entry.ts +84 -0
  114. package/src/tools/create-task.ts +66 -0
  115. package/src/tools/decision-comments.ts +103 -0
  116. package/src/tools/get-document.ts +80 -0
  117. package/src/tools/get-project-memory.ts +65 -0
  118. package/src/tools/get-related-entities.ts +73 -0
  119. package/src/tools/governance.ts +165 -0
  120. package/src/tools/ingest-document.ts +65 -0
  121. package/src/tools/letter-inbox.ts +157 -0
  122. package/src/tools/letters.ts +144 -0
  123. package/src/tools/list-documents.ts +58 -0
  124. package/src/tools/list-tasks.ts +57 -0
  125. package/src/tools/project-list.ts +52 -0
  126. package/src/tools/reviews.ts +281 -0
  127. package/src/tools/search-knowledge.ts +68 -0
  128. package/src/tools/sessions.ts +157 -0
  129. package/src/tools/skill-assign.ts +142 -0
  130. package/src/tools/skills.ts +255 -0
  131. package/src/tools/update-task-status.ts +64 -0
  132. package/tsconfig.json +20 -0
  133. package/vitest.config.ts +8 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Tests for task_list and doc_list tools.
3
+ */
4
+
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
6
+ import { mockApiError, mockApiSuccess, parseToolResponse, TEST_IDS } from './helpers'
7
+
8
+ vi.mock('../nexus-api.js', () => ({
9
+ nexusPost: vi.fn(),
10
+ }))
11
+
12
+ import { nexusPost } from '../nexus-api.js'
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // task_list
16
+ // ---------------------------------------------------------------------------
17
+
18
+ describe('task_list', () => {
19
+ beforeEach(() => {
20
+ vi.restoreAllMocks()
21
+ })
22
+
23
+ it('should list tasks for a project', async () => {
24
+ vi.mocked(nexusPost).mockResolvedValue(
25
+ mockApiSuccess({
26
+ action: 'task_list',
27
+ project_id: TEST_IDS.projectId,
28
+ count: 2,
29
+ tasks: [
30
+ {
31
+ id: TEST_IDS.taskId,
32
+ title: 'Implement feature',
33
+ status: 'open',
34
+ priority: 'high',
35
+ },
36
+ {
37
+ id: TEST_IDS.noteId,
38
+ title: 'Fix bug',
39
+ status: 'in_progress',
40
+ priority: 'normal',
41
+ },
42
+ ],
43
+ }),
44
+ )
45
+
46
+ const { listTasks } = await import('../tools/list-tasks.js')
47
+ const result = await listTasks({
48
+ project_id: TEST_IDS.projectId,
49
+ user_id: TEST_IDS.userId,
50
+ })
51
+
52
+ expect(result.isError).toBeUndefined()
53
+ const parsed = parseToolResponse(result)
54
+ expect(parsed.action).toBe('task_list')
55
+ expect(parsed.count).toBe(2)
56
+ expect(parsed.tasks).toHaveLength(2)
57
+ })
58
+
59
+ it('should pass status_filter to API', async () => {
60
+ vi.mocked(nexusPost).mockResolvedValue(
61
+ mockApiSuccess({
62
+ action: 'task_list',
63
+ project_id: TEST_IDS.projectId,
64
+ count: 1,
65
+ tasks: [
66
+ { id: TEST_IDS.taskId, title: 'Open task', status: 'open' },
67
+ ],
68
+ }),
69
+ )
70
+
71
+ const { listTasks } = await import('../tools/list-tasks.js')
72
+ await listTasks({
73
+ project_id: TEST_IDS.projectId,
74
+ status_filter: ['open', 'in_progress'],
75
+ user_id: TEST_IDS.userId,
76
+ })
77
+
78
+ expect(vi.mocked(nexusPost)).toHaveBeenCalledWith('/api/mcp/tasks', {
79
+ action: 'task_list',
80
+ project_id: TEST_IDS.projectId,
81
+ status_filter: ['open', 'in_progress'],
82
+ limit: 50,
83
+ })
84
+ })
85
+
86
+ it('should return error on API failure', async () => {
87
+ vi.mocked(nexusPost).mockResolvedValue(mockApiError('Project not found', 404))
88
+
89
+ const { listTasks } = await import('../tools/list-tasks.js')
90
+ const result = await listTasks({
91
+ project_id: TEST_IDS.projectId,
92
+ user_id: TEST_IDS.userId,
93
+ })
94
+
95
+ expect(result.isError).toBe(true)
96
+ })
97
+
98
+ it('should handle empty task list', async () => {
99
+ vi.mocked(nexusPost).mockResolvedValue(
100
+ mockApiSuccess({
101
+ action: 'task_list',
102
+ project_id: TEST_IDS.projectId,
103
+ count: 0,
104
+ tasks: [],
105
+ }),
106
+ )
107
+
108
+ const { listTasks } = await import('../tools/list-tasks.js')
109
+ const result = await listTasks({
110
+ project_id: TEST_IDS.projectId,
111
+ user_id: TEST_IDS.userId,
112
+ })
113
+
114
+ expect(result.isError).toBeUndefined()
115
+ const parsed = parseToolResponse(result)
116
+ expect(parsed.count).toBe(0)
117
+ expect(parsed.tasks).toHaveLength(0)
118
+ })
119
+ })
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // doc_list
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe('doc_list', () => {
126
+ beforeEach(() => {
127
+ vi.restoreAllMocks()
128
+ })
129
+
130
+ it('should list documents for a project', async () => {
131
+ vi.mocked(nexusPost).mockResolvedValue(
132
+ mockApiSuccess({
133
+ action: 'doc_list',
134
+ project_id: TEST_IDS.projectId,
135
+ count: 2,
136
+ documents: [
137
+ {
138
+ id: TEST_IDS.documentId,
139
+ title: 'Research Findings',
140
+ source: 'agent:app-agent',
141
+ classification: 'unclassified',
142
+ },
143
+ {
144
+ id: TEST_IDS.noteId,
145
+ title: 'E2E Test Plan',
146
+ source: 'mcp',
147
+ classification: 'unclassified',
148
+ },
149
+ ],
150
+ }),
151
+ )
152
+
153
+ const { listDocuments } = await import('../tools/list-documents.js')
154
+ const result = await listDocuments({
155
+ project_id: TEST_IDS.projectId,
156
+ user_id: TEST_IDS.userId,
157
+ })
158
+
159
+ expect(result.isError).toBeUndefined()
160
+ const parsed = parseToolResponse(result)
161
+ expect(parsed.action).toBe('doc_list')
162
+ expect(parsed.count).toBe(2)
163
+ expect(parsed.documents).toHaveLength(2)
164
+ })
165
+
166
+ it('should pass source filter to API', async () => {
167
+ vi.mocked(nexusPost).mockResolvedValue(
168
+ mockApiSuccess({
169
+ action: 'doc_list',
170
+ project_id: TEST_IDS.projectId,
171
+ count: 1,
172
+ documents: [
173
+ { id: TEST_IDS.documentId, title: 'Agent doc', source: 'agent:app-agent' },
174
+ ],
175
+ }),
176
+ )
177
+
178
+ const { listDocuments } = await import('../tools/list-documents.js')
179
+ await listDocuments({
180
+ project_id: TEST_IDS.projectId,
181
+ source: 'agent:app-agent',
182
+ user_id: TEST_IDS.userId,
183
+ })
184
+
185
+ expect(vi.mocked(nexusPost)).toHaveBeenCalledWith('/api/mcp/documents', {
186
+ action: 'doc_list',
187
+ project_id: TEST_IDS.projectId,
188
+ source: 'agent:app-agent',
189
+ limit: 50,
190
+ })
191
+ })
192
+
193
+ it('should return error on API failure', async () => {
194
+ vi.mocked(nexusPost).mockResolvedValue(mockApiError('No access to this project', 403))
195
+
196
+ const { listDocuments } = await import('../tools/list-documents.js')
197
+ const result = await listDocuments({
198
+ project_id: TEST_IDS.projectId,
199
+ user_id: TEST_IDS.userId,
200
+ })
201
+
202
+ expect(result.isError).toBe(true)
203
+ })
204
+
205
+ it('should handle empty document list', async () => {
206
+ vi.mocked(nexusPost).mockResolvedValue(
207
+ mockApiSuccess({
208
+ action: 'doc_list',
209
+ project_id: TEST_IDS.projectId,
210
+ count: 0,
211
+ documents: [],
212
+ }),
213
+ )
214
+
215
+ const { listDocuments } = await import('../tools/list-documents.js')
216
+ const result = await listDocuments({
217
+ project_id: TEST_IDS.projectId,
218
+ user_id: TEST_IDS.userId,
219
+ })
220
+
221
+ expect(result.isError).toBeUndefined()
222
+ const parsed = parseToolResponse(result)
223
+ expect(parsed.count).toBe(0)
224
+ expect(parsed.documents).toHaveLength(0)
225
+ })
226
+ })
package/src/auth.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * MCP Authentication Layer
3
+ *
4
+ * Resolves the NEXUS_PRIVATE_TOKEN environment variable at server startup
5
+ * to a verified user identity by calling the Nexus API identity endpoint.
6
+ *
7
+ * Flow:
8
+ * 1. Read NEXUS_PRIVATE_TOKEN from process.env
9
+ * 2. Call GET /api/mcp/identity with Bearer token
10
+ * 3. Cache resolved identity for the lifetime of the MCP process
11
+ */
12
+
13
+ import { nexusGet } from './nexus-api.js'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface McpIdentity {
20
+ userId: string
21
+ email: string | null
22
+ displayName: string | null
23
+ isPlatformAdmin: boolean
24
+ isPlatformOwner: boolean
25
+ tenantId: string | null
26
+ memberships: Array<{ project_id: string; role: string }>
27
+ agentAssignments: Array<{
28
+ project_id: string
29
+ agent_id: string
30
+ agent_owner: string
31
+ }>
32
+ }
33
+
34
+ // Module-level cache: resolved once at startup
35
+ let _identity: McpIdentity | null = null
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Public API
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Resolve the MCP server identity from NEXUS_PRIVATE_TOKEN.
43
+ * Called once at server startup. Caches the result.
44
+ * Throws if no valid token is available.
45
+ */
46
+ export async function initIdentity(): Promise<McpIdentity> {
47
+ if (_identity) return _identity
48
+
49
+ const rawToken = process.env.NEXUS_PRIVATE_TOKEN
50
+ if (!rawToken) {
51
+ throw new Error(
52
+ 'MCP: NEXUS_PRIVATE_TOKEN is not set. The MCP server requires a valid nxs_pat_* token.',
53
+ )
54
+ }
55
+
56
+ const result = await nexusGet<McpIdentity>('/api/mcp/identity')
57
+
58
+ if (!result.ok || !result.data) {
59
+ throw new Error(
60
+ `MCP: Identity resolution failed: ${result.error ?? 'Unknown error'}. Check that the token is valid, not expired, and not revoked.`,
61
+ )
62
+ }
63
+
64
+ _identity = result.data
65
+
66
+ console.error(
67
+ `[nexus-mcp] Identity resolved: ${_identity.displayName ?? _identity.email ?? _identity.userId} (admin=${_identity.isPlatformAdmin}, owner=${_identity.isPlatformOwner})`,
68
+ )
69
+
70
+ return _identity
71
+ }
72
+
73
+ /**
74
+ * Get the cached MCP identity. Must call initIdentity() first.
75
+ */
76
+ export function getIdentity(): McpIdentity {
77
+ if (!_identity) {
78
+ throw new Error('MCP: Identity not initialized. Call initIdentity() first.')
79
+ }
80
+ return _identity
81
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Nexus API client for MCP server context.
3
+ *
4
+ * Replaces direct Supabase access with HTTP calls to the Nexus API.
5
+ * All operations go through /api/mcp/* endpoints, authenticated
6
+ * via the NEXUS_PRIVATE_TOKEN (nxs_pat_*) Bearer token.
7
+ */
8
+
9
+ let _baseUrl: string | null = null
10
+ let _token: string | null = null
11
+
12
+ /**
13
+ * Reset the cached API configuration. Used by E2E tests to switch
14
+ * from mocked env vars to real credentials.
15
+ */
16
+ export function resetApiConfig(): void {
17
+ _baseUrl = null
18
+ _token = null
19
+ }
20
+
21
+ function getConfig(): { baseUrl: string; token: string } {
22
+ if (_baseUrl && _token) return { baseUrl: _baseUrl, token: _token }
23
+
24
+ const url = process.env.NEXUS_API_URL ?? process.env.NEXUS_URL
25
+ const token = process.env.NEXUS_PRIVATE_TOKEN
26
+
27
+ if (!url) {
28
+ throw new Error(
29
+ 'MCP: Missing NEXUS_API_URL. Set the base URL of the Nexus app (e.g. https://nexus.gatewarden.eu).',
30
+ )
31
+ }
32
+ if (!token) {
33
+ throw new Error(
34
+ 'MCP: Missing NEXUS_PRIVATE_TOKEN. The MCP server requires a valid nxs_pat_* token.',
35
+ )
36
+ }
37
+
38
+ // Normalize: strip trailing slash
39
+ _baseUrl = url.replace(/\/$/, '')
40
+ _token = token.trim()
41
+
42
+ return { baseUrl: _baseUrl, token: _token }
43
+ }
44
+
45
+ export interface NexusApiResponse<T = unknown> {
46
+ ok: boolean
47
+ status: number
48
+ data: T | null
49
+ error: string | null
50
+ }
51
+
52
+ /** Default HTTP timeout in milliseconds (30 seconds). */
53
+ const DEFAULT_TIMEOUT_MS = 30_000
54
+
55
+ /**
56
+ * Make an authenticated request to the Nexus API.
57
+ */
58
+ export async function nexusApi<T = unknown>(
59
+ path: string,
60
+ options: {
61
+ method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
62
+ body?: unknown
63
+ timeoutMs?: number
64
+ } = {},
65
+ ): Promise<NexusApiResponse<T>> {
66
+ const { baseUrl, token } = getConfig()
67
+ const method = options.method ?? 'GET'
68
+ const url = `${baseUrl}${path}`
69
+
70
+ const headers: Record<string, string> = {
71
+ Authorization: `Bearer ${token}`,
72
+ 'Content-Type': 'application/json',
73
+ }
74
+
75
+ try {
76
+ const fetchOpts: RequestInit = {
77
+ method,
78
+ headers,
79
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
80
+ }
81
+ if (options.body !== undefined) {
82
+ fetchOpts.body = JSON.stringify(options.body)
83
+ }
84
+
85
+ const response = await fetch(url, fetchOpts)
86
+ const data = (await response.json()) as T & { error?: string }
87
+
88
+ if (!response.ok) {
89
+ return {
90
+ ok: false,
91
+ status: response.status,
92
+ data: null,
93
+ error:
94
+ (data as Record<string, unknown>)?.error as string ??
95
+ `HTTP ${response.status}`,
96
+ }
97
+ }
98
+
99
+ return { ok: true, status: response.status, data, error: null }
100
+ } catch (err) {
101
+ return {
102
+ ok: false,
103
+ status: 0,
104
+ data: null,
105
+ error: err instanceof Error ? err.message : 'Unknown fetch error',
106
+ }
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Convenience: POST to a Nexus MCP endpoint.
112
+ */
113
+ export async function nexusPost<T = unknown>(
114
+ path: string,
115
+ body: unknown,
116
+ ): Promise<NexusApiResponse<T>> {
117
+ return nexusApi<T>(path, { method: 'POST', body })
118
+ }
119
+
120
+ /**
121
+ * Convenience: GET from a Nexus MCP endpoint.
122
+ */
123
+ export async function nexusGet<T = unknown>(
124
+ path: string,
125
+ ): Promise<NexusApiResponse<T>> {
126
+ return nexusApi<T>(path, { method: 'GET' })
127
+ }