@flowrelay/mcp-server 0.4.1 → 0.8.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/README.md +104 -101
- package/dist/api.d.ts +86 -35
- package/dist/api.js +49 -9
- package/dist/index.js +178 -18
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,101 +1,104 @@
|
|
|
1
|
-
# Flow Relay MCP Server
|
|
2
|
-
|
|
3
|
-
Flow Relay MCP Server adds project-aware, multi-tenant Flow Relay tools to MCP clients such as Claude Desktop and Claude Code.
|
|
4
|
-
|
|
5
|
-
It connects to Flow Relay API v1 using an API key and supports
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
-
|
|
96
|
-
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
-
|
|
101
|
-
|
|
1
|
+
# Flow Relay MCP Server
|
|
2
|
+
|
|
3
|
+
Flow Relay MCP Server adds project-aware, multi-tenant Flow Relay tools to MCP clients such as Claude Desktop and Claude Code.
|
|
4
|
+
|
|
5
|
+
It connects to Flow Relay API v1 using an API key and supports:
|
|
6
|
+
|
|
7
|
+
- Project scope (personal project or organization project). Every handoff and AI insight is tied to a project. Events and integrations remain user-level.
|
|
8
|
+
|
|
9
|
+
## Package
|
|
10
|
+
|
|
11
|
+
- Name: @flowrelay/mcp-server
|
|
12
|
+
- Version: 0.8.0
|
|
13
|
+
|
|
14
|
+
## What Is Included
|
|
15
|
+
|
|
16
|
+
The server currently exposes these tools:
|
|
17
|
+
|
|
18
|
+
- get_workspace_context
|
|
19
|
+
- list_projects
|
|
20
|
+
- set_active_project
|
|
21
|
+
- list_handoffs (returns project-specific handoffs, or an aggregated view of all accessible project handoffs if no project is active)
|
|
22
|
+
- generate_handoff (processed asynchronously, and the server automatically polls until the job finishes. Requires active project or project_id.)
|
|
23
|
+
- generate_correlation_insight
|
|
24
|
+
- generate_onboarding_brief
|
|
25
|
+
- generate_architecture_insight
|
|
26
|
+
- list_insights
|
|
27
|
+
- list_integrations
|
|
28
|
+
- list_events
|
|
29
|
+
- list_untracked_resources (lists event-producing resources not scoped to any project — useful for discovering untracked activity)
|
|
30
|
+
- discord_list_channels
|
|
31
|
+
- discord_send_message
|
|
32
|
+
|
|
33
|
+
## Environment Variables
|
|
34
|
+
|
|
35
|
+
Required:
|
|
36
|
+
|
|
37
|
+
- FLOWRELAY_API_KEY
|
|
38
|
+
|
|
39
|
+
Optional:
|
|
40
|
+
|
|
41
|
+
- FLOWRELAY_PROJECT_ID
|
|
42
|
+
- FLOWRELAY_BASE_URL
|
|
43
|
+
|
|
44
|
+
If FLOWRELAY_PROJECT_ID is set, it becomes the default project context for project-aware tools unless you override it per call.
|
|
45
|
+
|
|
46
|
+
## Quick Start (Claude Desktop)
|
|
47
|
+
|
|
48
|
+
Add this to your claude_desktop_config.json:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"flowrelay": {
|
|
54
|
+
"command": "npx",
|
|
55
|
+
"args": ["-y", "@flowrelay/mcp-server"],
|
|
56
|
+
"env": {
|
|
57
|
+
"FLOWRELAY_API_KEY": "fr_your_api_key_here",
|
|
58
|
+
"FLOWRELAY_PROJECT_ID": "optional_project_id"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Multi-Tenant Behavior
|
|
66
|
+
|
|
67
|
+
- Every handoff and insight is tied to a project; you need an active project (`set_active_project`) or `project_id`. Events and integrations remain user-level.
|
|
68
|
+
- You can select a project during the MCP session with set_active_project.
|
|
69
|
+
- You can override scope per call by passing project_id where supported.
|
|
70
|
+
|
|
71
|
+
Recommended flow:
|
|
72
|
+
|
|
73
|
+
1. Call get_workspace_context
|
|
74
|
+
2. Call list_projects
|
|
75
|
+
3. Call set_active_project
|
|
76
|
+
4. Run handoff and query tools in the selected scope
|
|
77
|
+
|
|
78
|
+
## Local Development
|
|
79
|
+
|
|
80
|
+
From this folder:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm install
|
|
84
|
+
npm run build
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Create a tarball package:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npm pack
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Troubleshooting
|
|
94
|
+
|
|
95
|
+
- Error: Missing FLOWRELAY_API_KEY
|
|
96
|
+
- Set FLOWRELAY_API_KEY in your MCP client configuration.
|
|
97
|
+
- Project not found or inaccessible
|
|
98
|
+
- Run list_projects and use one of the returned IDs.
|
|
99
|
+
- No events or handoffs returned
|
|
100
|
+
- Verify active scope and data availability in that scope.
|
|
101
|
+
|
|
102
|
+
## Related Docs
|
|
103
|
+
|
|
104
|
+
- Repository overview: ../README.md
|
package/dist/api.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Flow Relay API client
|
|
2
|
+
* Flow Relay API client – talks to flowrelay.it/api/v1/*
|
|
3
3
|
*/
|
|
4
4
|
export type AccountType = 'personal' | 'business';
|
|
5
5
|
export type AccessRole = 'owner' | 'admin' | 'member';
|
|
@@ -28,51 +28,101 @@ export interface TenantContext {
|
|
|
28
28
|
organizations: TenantOrganization[];
|
|
29
29
|
projects: TenantProject[];
|
|
30
30
|
}
|
|
31
|
+
export type AiJobStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
|
32
|
+
export interface AiJob {
|
|
33
|
+
id: string;
|
|
34
|
+
project_id: string;
|
|
35
|
+
kind: string;
|
|
36
|
+
status: AiJobStatus;
|
|
37
|
+
result_kind: 'handoff' | 'insight' | null;
|
|
38
|
+
result_id: string | null;
|
|
39
|
+
error: string | null;
|
|
40
|
+
error_code: string | null;
|
|
41
|
+
error_meta: Record<string, unknown>;
|
|
42
|
+
created_at: string;
|
|
43
|
+
updated_at: string;
|
|
44
|
+
}
|
|
45
|
+
export interface HandoffResult {
|
|
46
|
+
id: string;
|
|
47
|
+
user_id: string;
|
|
48
|
+
project_id: string | null;
|
|
49
|
+
project_name?: string | null;
|
|
50
|
+
scope_type?: 'personal' | 'project';
|
|
51
|
+
title: string;
|
|
52
|
+
summary: string;
|
|
53
|
+
status: string;
|
|
54
|
+
sources: string[];
|
|
55
|
+
key_changes?: string[];
|
|
56
|
+
decisions: string[];
|
|
57
|
+
open_questions: string[];
|
|
58
|
+
next_steps: string[];
|
|
59
|
+
created_at: string;
|
|
60
|
+
updated_at?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface InsightResult {
|
|
63
|
+
id: string;
|
|
64
|
+
project_id: string;
|
|
65
|
+
requested_by: string;
|
|
66
|
+
kind: 'onboarding_brief' | 'cross_source_correlation' | 'architecture_insight';
|
|
67
|
+
title: string;
|
|
68
|
+
summary: string;
|
|
69
|
+
data: Record<string, unknown>;
|
|
70
|
+
model_used: string;
|
|
71
|
+
token_usage: {
|
|
72
|
+
prompt_tokens: number;
|
|
73
|
+
completion_tokens: number;
|
|
74
|
+
total_tokens: number;
|
|
75
|
+
};
|
|
76
|
+
status: string;
|
|
77
|
+
related_event_ids: string[];
|
|
78
|
+
created_at: string;
|
|
79
|
+
updated_at?: string;
|
|
80
|
+
}
|
|
81
|
+
export interface UntrackedResource {
|
|
82
|
+
source: string;
|
|
83
|
+
resource_id: string;
|
|
84
|
+
resource_name: string;
|
|
85
|
+
resource_type: string;
|
|
86
|
+
}
|
|
87
|
+
export type GenerateHandoffResponse = {
|
|
88
|
+
handoff: HandoffResult;
|
|
89
|
+
} | {
|
|
90
|
+
jobId: string;
|
|
91
|
+
status: AiJobStatus;
|
|
92
|
+
};
|
|
93
|
+
export type GenerateInsightResponse = {
|
|
94
|
+
jobId: string;
|
|
95
|
+
status: AiJobStatus;
|
|
96
|
+
};
|
|
31
97
|
export declare class FlowRelayAPI {
|
|
32
98
|
private baseUrl;
|
|
33
99
|
private apiKey;
|
|
34
100
|
constructor(apiKey: string, baseUrl?: string);
|
|
101
|
+
private requestRaw;
|
|
35
102
|
private request;
|
|
36
103
|
listProjects(): Promise<TenantContext>;
|
|
37
104
|
listHandoffs(status?: string, limit?: number, projectId?: string | null): Promise<{
|
|
38
|
-
handoffs:
|
|
39
|
-
id: string;
|
|
40
|
-
user_id: string;
|
|
41
|
-
project_id: string | null;
|
|
42
|
-
project_name?: string | null;
|
|
43
|
-
scope_type?: "personal" | "project";
|
|
44
|
-
title: string;
|
|
45
|
-
summary: string;
|
|
46
|
-
status: string;
|
|
47
|
-
sources: string[];
|
|
48
|
-
key_changes?: string[];
|
|
49
|
-
decisions: string[];
|
|
50
|
-
open_questions: string[];
|
|
51
|
-
next_steps: string[];
|
|
52
|
-
created_at: string;
|
|
53
|
-
updated_at: string;
|
|
54
|
-
}>;
|
|
105
|
+
handoffs: HandoffResult[];
|
|
55
106
|
}>;
|
|
56
|
-
generateHandoff(sources
|
|
107
|
+
generateHandoff(sources: string[] | undefined, filters: Record<string, {
|
|
57
108
|
projects?: string[];
|
|
58
109
|
eventTypes?: string[];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
created_at: string;
|
|
74
|
-
};
|
|
110
|
+
}> | undefined, projectId: string): Promise<GenerateHandoffResponse>;
|
|
111
|
+
getJob(jobId: string): Promise<{
|
|
112
|
+
job: AiJob;
|
|
113
|
+
result: HandoffResult | InsightResult | null;
|
|
114
|
+
}>;
|
|
115
|
+
waitForJob(jobId: string, opts?: {
|
|
116
|
+
intervalMs?: number;
|
|
117
|
+
timeoutMs?: number;
|
|
118
|
+
}): Promise<{
|
|
119
|
+
job: AiJob;
|
|
120
|
+
result: HandoffResult | InsightResult | null;
|
|
121
|
+
}>;
|
|
122
|
+
listInsights(projectId: string, kind?: string, status?: string, limit?: number): Promise<{
|
|
123
|
+
insights: InsightResult[];
|
|
75
124
|
}>;
|
|
125
|
+
generateInsight(projectId: string, kind: 'correlation' | 'onboarding' | 'architecture', body?: Record<string, unknown>): Promise<GenerateInsightResponse>;
|
|
76
126
|
listIntegrations(projectId?: string | null): Promise<{
|
|
77
127
|
integrations: Array<{
|
|
78
128
|
source: string;
|
|
@@ -108,4 +158,5 @@ export declare class FlowRelayAPI {
|
|
|
108
158
|
ok: boolean;
|
|
109
159
|
message_id: string;
|
|
110
160
|
}>;
|
|
161
|
+
listUntrackedResources(): Promise<UntrackedResource[]>;
|
|
111
162
|
}
|
package/dist/api.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Flow Relay API client
|
|
2
|
+
* Flow Relay API client – talks to flowrelay.it/api/v1/*
|
|
3
3
|
*/
|
|
4
4
|
const DEFAULT_BASE_URL = 'https://www.flowrelay.it';
|
|
5
5
|
export class FlowRelayAPI {
|
|
@@ -9,7 +9,7 @@ export class FlowRelayAPI {
|
|
|
9
9
|
this.apiKey = apiKey;
|
|
10
10
|
this.baseUrl = baseUrl ?? DEFAULT_BASE_URL;
|
|
11
11
|
}
|
|
12
|
-
async
|
|
12
|
+
async requestRaw(path, options) {
|
|
13
13
|
const url = `${this.baseUrl}/api/v1${path}`;
|
|
14
14
|
const res = await fetch(url, {
|
|
15
15
|
...options,
|
|
@@ -23,10 +23,13 @@ export class FlowRelayAPI {
|
|
|
23
23
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
24
24
|
throw new Error(body.error ?? `API error ${res.status}`);
|
|
25
25
|
}
|
|
26
|
-
if (res.status === 204)
|
|
27
|
-
return {};
|
|
28
|
-
}
|
|
29
|
-
|
|
26
|
+
if (res.status === 204)
|
|
27
|
+
return { status: 204, body: {} };
|
|
28
|
+
return { status: res.status, body: (await res.json()) };
|
|
29
|
+
}
|
|
30
|
+
async request(path, options) {
|
|
31
|
+
const { body } = await this.requestRaw(path, options);
|
|
32
|
+
return body;
|
|
30
33
|
}
|
|
31
34
|
async listProjects() {
|
|
32
35
|
return this.request('/projects');
|
|
@@ -45,12 +48,46 @@ export class FlowRelayAPI {
|
|
|
45
48
|
body.sources = sources;
|
|
46
49
|
if (filters && Object.keys(filters).length > 0)
|
|
47
50
|
body.filters = filters;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return this.request('/handoffs', {
|
|
51
|
+
body.project_id = projectId;
|
|
52
|
+
const { status, body: data } = await this.requestRaw('/handoffs', {
|
|
51
53
|
method: 'POST',
|
|
52
54
|
body: JSON.stringify(body),
|
|
53
55
|
});
|
|
56
|
+
// 202 → async job; 200 → inline handoff. Caller branches on the shape.
|
|
57
|
+
void status;
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
60
|
+
async getJob(jobId) {
|
|
61
|
+
return (await this.requestRaw(`/jobs/${jobId}`)).body;
|
|
62
|
+
}
|
|
63
|
+
async waitForJob(jobId, opts = {}) {
|
|
64
|
+
const intervalMs = opts.intervalMs ?? 2500;
|
|
65
|
+
const timeoutMs = opts.timeoutMs ?? 180_000; // 3 min hard cap
|
|
66
|
+
const deadline = Date.now() + timeoutMs;
|
|
67
|
+
while (true) {
|
|
68
|
+
const res = await this.getJob(jobId);
|
|
69
|
+
if (res.job.status === 'completed' || res.job.status === 'failed') {
|
|
70
|
+
return res;
|
|
71
|
+
}
|
|
72
|
+
if (Date.now() > deadline) {
|
|
73
|
+
throw new Error(`AI Job timed out after ${Math.round(timeoutMs / 1000)}s (job ${jobId} still ${res.job.status}).`);
|
|
74
|
+
}
|
|
75
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async listInsights(projectId, kind, status = 'active', limit = 20) {
|
|
79
|
+
const params = new URLSearchParams();
|
|
80
|
+
if (kind)
|
|
81
|
+
params.set('kind', kind);
|
|
82
|
+
params.set('status', status);
|
|
83
|
+
params.set('limit', String(limit));
|
|
84
|
+
return this.request(`/projects/${projectId}/insights?${params.toString()}`);
|
|
85
|
+
}
|
|
86
|
+
async generateInsight(projectId, kind, body) {
|
|
87
|
+
return this.request(`/projects/${projectId}/insights/${kind}`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
body: JSON.stringify(body ?? {}),
|
|
90
|
+
});
|
|
54
91
|
}
|
|
55
92
|
async listIntegrations(projectId) {
|
|
56
93
|
const params = new URLSearchParams();
|
|
@@ -77,4 +114,7 @@ export class FlowRelayAPI {
|
|
|
77
114
|
body: JSON.stringify({ channel_id: channelId, content }),
|
|
78
115
|
});
|
|
79
116
|
}
|
|
117
|
+
async listUntrackedResources() {
|
|
118
|
+
return this.request('/integrations/untracked');
|
|
119
|
+
}
|
|
80
120
|
}
|
package/dist/index.js
CHANGED
|
@@ -29,7 +29,7 @@ function normalizeProjectId(value) {
|
|
|
29
29
|
}
|
|
30
30
|
function formatProjectScope(project) {
|
|
31
31
|
if (!project)
|
|
32
|
-
return 'No project (
|
|
32
|
+
return 'No project selected (required for handoffs & insights)';
|
|
33
33
|
if (project.project_type === 'personal') {
|
|
34
34
|
return `Personal project: ${project.name}`;
|
|
35
35
|
}
|
|
@@ -50,7 +50,7 @@ const api = new FlowRelayAPI(apiKey, process.env.FLOWRELAY_BASE_URL);
|
|
|
50
50
|
let activeProjectId = normalizeProjectId(process.env.FLOWRELAY_PROJECT_ID);
|
|
51
51
|
const server = new McpServer({
|
|
52
52
|
name: 'flowrelay',
|
|
53
|
-
version: '0.
|
|
53
|
+
version: '0.8.0',
|
|
54
54
|
});
|
|
55
55
|
async function getTenantContext() {
|
|
56
56
|
const context = await api.listProjects();
|
|
@@ -73,6 +73,20 @@ async function resolveProject(projectId) {
|
|
|
73
73
|
}
|
|
74
74
|
return { projectId: resolvedProjectId, project };
|
|
75
75
|
}
|
|
76
|
+
async function requireProject(projectId) {
|
|
77
|
+
const explicitProjectId = normalizeProjectId(projectId);
|
|
78
|
+
const resolvedProjectId = explicitProjectId ?? activeProjectId;
|
|
79
|
+
if (!resolvedProjectId) {
|
|
80
|
+
throw new Error('A project is required. Use set_active_project or pass project_id. Run list_projects to see available projects.');
|
|
81
|
+
}
|
|
82
|
+
const context = await getTenantContext();
|
|
83
|
+
const project = context.projects.find((candidate) => candidate.id === resolvedProjectId) ?? null;
|
|
84
|
+
if (!project) {
|
|
85
|
+
activeProjectId = null;
|
|
86
|
+
throw new Error('A project is required. The selected project was not found or is inaccessible. Use set_active_project or pass project_id. Run list_projects to see available projects.');
|
|
87
|
+
}
|
|
88
|
+
return { projectId: resolvedProjectId, project };
|
|
89
|
+
}
|
|
76
90
|
// ── Tool: workspace context ─────────────────────────────────────────
|
|
77
91
|
server.tool('get_workspace_context', 'Show Flow Relay tenant context: personal/business mode, active project scope, and current role.', {}, async () => {
|
|
78
92
|
const context = await getTenantContext();
|
|
@@ -99,7 +113,7 @@ server.tool('list_projects', 'List all projects available to this API key, inclu
|
|
|
99
113
|
return {
|
|
100
114
|
content: [{
|
|
101
115
|
type: 'text',
|
|
102
|
-
text: 'No accessible projects found.
|
|
116
|
+
text: 'No accessible projects found. Create a project in Flow Relay or get added to a project team.',
|
|
103
117
|
}],
|
|
104
118
|
};
|
|
105
119
|
}
|
|
@@ -124,7 +138,7 @@ server.tool('set_active_project', 'Set or clear the active project context used
|
|
|
124
138
|
}, async ({ project_id, clear }) => {
|
|
125
139
|
if (clear || !normalizeProjectId(project_id)) {
|
|
126
140
|
activeProjectId = null;
|
|
127
|
-
return { content: [{ type: 'text', text: 'Active project cleared.
|
|
141
|
+
return { content: [{ type: 'text', text: 'Active project cleared. Handoff/insight generation requires selecting a project.' }] };
|
|
128
142
|
}
|
|
129
143
|
const context = await getTenantContext();
|
|
130
144
|
const selected = context.projects.find((project) => project.id === project_id);
|
|
@@ -145,23 +159,29 @@ server.tool('set_active_project', 'Set or clear the active project context used
|
|
|
145
159
|
};
|
|
146
160
|
});
|
|
147
161
|
// ── Tool: list handoffs ──────────────────────────────────────────────
|
|
148
|
-
server.tool('list_handoffs', 'List Flow Relay handoffs in the current tenant scope (
|
|
162
|
+
server.tool('list_handoffs', 'List Flow Relay handoffs in the current tenant scope (all accessible projects or selected project).', {
|
|
149
163
|
status: z.enum(['active', 'archived', 'completed']).default('active').describe('Filter by status'),
|
|
150
164
|
limit: z.number().min(1).max(50).default(10).describe('Max number of handoffs to return'),
|
|
151
165
|
project_id: z.string().optional().describe('Optional project scope override for this call.'),
|
|
152
166
|
}, async ({ status, limit, project_id }) => {
|
|
153
|
-
const
|
|
154
|
-
const
|
|
167
|
+
const explicitProjectId = normalizeProjectId(project_id);
|
|
168
|
+
const resolvedProjectId = explicitProjectId ?? activeProjectId;
|
|
169
|
+
let resolvedProject = null;
|
|
170
|
+
if (resolvedProjectId) {
|
|
171
|
+
const context = await getTenantContext();
|
|
172
|
+
resolvedProject = context.projects.find((p) => p.id === resolvedProjectId) ?? null;
|
|
173
|
+
if (!resolvedProject)
|
|
174
|
+
activeProjectId = null;
|
|
175
|
+
}
|
|
176
|
+
const { handoffs } = await api.listHandoffs(status, limit, resolvedProject?.id ?? null);
|
|
155
177
|
if (handoffs.length === 0) {
|
|
156
|
-
const scopeLabel = formatProjectScope(
|
|
178
|
+
const scopeLabel = resolvedProject ? formatProjectScope(resolvedProject) : 'all projects';
|
|
157
179
|
return { content: [{ type: 'text', text: `No ${status} handoffs found in scope: ${scopeLabel}.` }] };
|
|
158
180
|
}
|
|
159
181
|
const text = handoffs.map((h) => {
|
|
160
182
|
let out = `## ${h.title}\n`;
|
|
161
183
|
out += `**Status:** ${h.status} · **Sources:** ${h.sources.join(', ') || 'all'}\n`;
|
|
162
|
-
|
|
163
|
-
out += `**Project:** ${h.project_name}\n`;
|
|
164
|
-
}
|
|
184
|
+
out += `**Project:** ${h.project_name ?? 'Unknown'}\n`;
|
|
165
185
|
out += `**Created:** ${new Date(h.created_at).toLocaleString()}\n\n`;
|
|
166
186
|
out += `${h.summary}\n`;
|
|
167
187
|
if (h.key_changes?.length)
|
|
@@ -188,12 +208,24 @@ server.tool('generate_handoff', 'Generate a new context handoff for personal sco
|
|
|
188
208
|
project_id: z.string().optional().describe('Optional project scope override for this call.'),
|
|
189
209
|
}, async ({ sources, filters, project_id }) => {
|
|
190
210
|
try {
|
|
191
|
-
const resolved = await
|
|
192
|
-
const
|
|
211
|
+
const resolved = await requireProject(project_id);
|
|
212
|
+
const res = await api.generateHandoff(sources, filters, resolved.projectId);
|
|
213
|
+
// Async path (project handoff): poll until the worker finishes.
|
|
214
|
+
let handoff;
|
|
215
|
+
if ('jobId' in res) {
|
|
216
|
+
const { job, result } = await api.waitForJob(res.jobId);
|
|
217
|
+
if (job.status === 'failed' || !result) {
|
|
218
|
+
const reason = job.error ?? 'unknown error';
|
|
219
|
+
return { content: [{ type: 'text', text: `Could not generate handoff: ${reason}` }] };
|
|
220
|
+
}
|
|
221
|
+
handoff = result;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
handoff = res.handoff;
|
|
225
|
+
}
|
|
193
226
|
let text = `# ${handoff.title}\n\n${handoff.summary}\n`;
|
|
194
|
-
if (handoff.project_name)
|
|
227
|
+
if (handoff.project_name)
|
|
195
228
|
text += `\n**Project:** ${handoff.project_name}\n`;
|
|
196
|
-
}
|
|
197
229
|
text += `\n**Sources:** ${handoff.sources.join(', ')}\n`;
|
|
198
230
|
if (handoff.key_changes?.length)
|
|
199
231
|
text += `\n**Key changes:**\n${handoff.key_changes.map((c) => `- ${c}`).join('\n')}\n`;
|
|
@@ -224,12 +256,38 @@ server.tool('list_integrations', 'List integrations in the current scope. In pro
|
|
|
224
256
|
if (i.scope === 'project') {
|
|
225
257
|
const status = i.connection_status ?? 'unknown';
|
|
226
258
|
const providers = i.providers_connected ?? 0;
|
|
227
|
-
return `- **${i.source}**${name}
|
|
259
|
+
return `- **${i.source}**${name} – status: ${status}, providers connected: ${providers}`;
|
|
228
260
|
}
|
|
229
|
-
return `- **${i.source}**${name}
|
|
261
|
+
return `- **${i.source}**${name} – connected ${new Date(i.connected_at).toLocaleDateString()}`;
|
|
230
262
|
}).join('\n');
|
|
231
263
|
return { content: [{ type: 'text', text: `**Connected integrations:**\n${text}` }] };
|
|
232
264
|
});
|
|
265
|
+
// ── Tool: list untracked resources ───────────────────────────────────
|
|
266
|
+
server.tool('list_untracked_resources', 'List discovered active resources across all connected user integrations that have not yet been assigned to any project.', {}, async () => {
|
|
267
|
+
try {
|
|
268
|
+
const resources = await api.listUntrackedResources();
|
|
269
|
+
if (resources.length === 0) {
|
|
270
|
+
return {
|
|
271
|
+
content: [{
|
|
272
|
+
type: 'text',
|
|
273
|
+
text: 'All discovered active resources are already tracked in your projects. Good job!',
|
|
274
|
+
}],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const text = resources.map((r) => {
|
|
278
|
+
return `- **[${r.source}]** ${r.resource_name} (type: ${r.resource_type}, id: ${r.resource_id})`;
|
|
279
|
+
}).join('\n');
|
|
280
|
+
return {
|
|
281
|
+
content: [{
|
|
282
|
+
type: 'text',
|
|
283
|
+
text: `**Untracked active resources:**\n${text}\n\n*Note: Map these resources to Flow Relay projects in the web dashboard or CLI to start tracking their events.*`,
|
|
284
|
+
}],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
return { content: [{ type: 'text', text: `Failed to list untracked resources: ${err.message}` }] };
|
|
289
|
+
}
|
|
290
|
+
});
|
|
233
291
|
// ── Tool: list recent events ─────────────────────────────────────────
|
|
234
292
|
server.tool('list_events', 'List recent context events in current scope (personal or selected project).', {
|
|
235
293
|
source: SourceEnum
|
|
@@ -259,7 +317,7 @@ server.tool('discord_list_channels', 'List text channels in your connected Disco
|
|
|
259
317
|
return { content: [{ type: 'text', text: 'No text channels found in the connected Discord server.' }] };
|
|
260
318
|
}
|
|
261
319
|
const text = channels.map((ch) => {
|
|
262
|
-
const topic = ch.topic ? `
|
|
320
|
+
const topic = ch.topic ? ` – ${ch.topic}` : '';
|
|
263
321
|
return `- **#${ch.name}** (${ch.id})${topic}`;
|
|
264
322
|
}).join('\n');
|
|
265
323
|
return { content: [{ type: 'text', text: `**Discord channels:**\n${text}` }] };
|
|
@@ -281,6 +339,108 @@ server.tool('discord_send_message', 'Send a message to a Discord channel in your
|
|
|
281
339
|
return { content: [{ type: 'text', text: `Failed to send message: ${err.message}` }] };
|
|
282
340
|
}
|
|
283
341
|
});
|
|
342
|
+
// ── Tool: generate correlation insight ──────────────────────────────
|
|
343
|
+
server.tool('generate_correlation_insight', 'Generate a cross-source correlation AI insight for the specified project scope.', {
|
|
344
|
+
project_id: z.string().describe('The project ID scope to generate the correlation insight for.'),
|
|
345
|
+
sources: z.array(SourceEnum).optional().describe('Filter events to specific sources (e.g. "github", "slack")'),
|
|
346
|
+
lookback_hours: z.number().optional().describe('Number of hours of activity to analyze'),
|
|
347
|
+
max_events: z.number().optional().describe('Maximum number of events to process'),
|
|
348
|
+
}, async ({ project_id, sources, lookback_hours, max_events }) => {
|
|
349
|
+
try {
|
|
350
|
+
const res = await api.generateInsight(project_id, 'correlation', {
|
|
351
|
+
sources,
|
|
352
|
+
lookbackHours: lookback_hours,
|
|
353
|
+
maxEvents: max_events,
|
|
354
|
+
});
|
|
355
|
+
const { job, result } = await api.waitForJob(res.jobId);
|
|
356
|
+
if (job.status === 'failed' || !result) {
|
|
357
|
+
const reason = job.error ?? 'unknown error';
|
|
358
|
+
return { content: [{ type: 'text', text: `Could not generate correlation insight: ${reason}` }] };
|
|
359
|
+
}
|
|
360
|
+
const insight = result;
|
|
361
|
+
return { content: [{ type: 'text', text: `# ${insight.title}\n\n${insight.summary}` }] };
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
return { content: [{ type: 'text', text: `Failed: ${err.message}` }] };
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
// ── Tool: generate onboarding brief ──────────────────────────────────
|
|
368
|
+
server.tool('generate_onboarding_brief', 'Generate an onboarding brief AI insight for the specified project scope.', {
|
|
369
|
+
project_id: z.string().describe('The project ID scope to generate the onboarding brief for.'),
|
|
370
|
+
sources: z.array(SourceEnum).optional().describe('Filter events to specific sources'),
|
|
371
|
+
new_member_role: z.string().optional().describe('Expected role/focus of the new team member'),
|
|
372
|
+
focus_area: z.string().optional().describe('Specific repository or feature area they will work on'),
|
|
373
|
+
lookback_days: z.number().optional().describe('Number of days of history to review'),
|
|
374
|
+
max_events: z.number().optional().describe('Maximum events to process'),
|
|
375
|
+
}, async ({ project_id, sources, new_member_role, focus_area, lookback_days, max_events }) => {
|
|
376
|
+
try {
|
|
377
|
+
const res = await api.generateInsight(project_id, 'onboarding', {
|
|
378
|
+
sources,
|
|
379
|
+
newMemberRole: new_member_role,
|
|
380
|
+
focusArea: focus_area,
|
|
381
|
+
lookbackDays: lookback_days,
|
|
382
|
+
maxEvents: max_events,
|
|
383
|
+
});
|
|
384
|
+
const { job, result } = await api.waitForJob(res.jobId);
|
|
385
|
+
if (job.status === 'failed' || !result) {
|
|
386
|
+
const reason = job.error ?? 'unknown error';
|
|
387
|
+
return { content: [{ type: 'text', text: `Could not generate onboarding brief: ${reason}` }] };
|
|
388
|
+
}
|
|
389
|
+
const insight = result;
|
|
390
|
+
return { content: [{ type: 'text', text: `# ${insight.title}\n\n${insight.summary}` }] };
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
return { content: [{ type: 'text', text: `Failed: ${err.message}` }] };
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
// ── Tool: generate architecture insight ─────────────────────────────
|
|
397
|
+
server.tool('generate_architecture_insight', 'Generate an architecture insight AI insight for the specified project scope.', {
|
|
398
|
+
project_id: z.string().describe('The project ID scope to generate the architecture insight for.'),
|
|
399
|
+
sources: z.array(SourceEnum).optional().describe('Filter events to specific sources'),
|
|
400
|
+
focus_question: z.string().optional().describe('Specific architectural question or component to focus on'),
|
|
401
|
+
lookback_days: z.number().optional().describe('Number of days of history to review'),
|
|
402
|
+
max_events: z.number().optional().describe('Maximum events to process'),
|
|
403
|
+
}, async ({ project_id, sources, focus_question, lookback_days, max_events }) => {
|
|
404
|
+
try {
|
|
405
|
+
const res = await api.generateInsight(project_id, 'architecture', {
|
|
406
|
+
sources,
|
|
407
|
+
focusQuestion: focus_question,
|
|
408
|
+
lookbackDays: lookback_days,
|
|
409
|
+
maxEvents: max_events,
|
|
410
|
+
});
|
|
411
|
+
const { job, result } = await api.waitForJob(res.jobId);
|
|
412
|
+
if (job.status === 'failed' || !result) {
|
|
413
|
+
const reason = job.error ?? 'unknown error';
|
|
414
|
+
return { content: [{ type: 'text', text: `Could not generate architecture insight: ${reason}` }] };
|
|
415
|
+
}
|
|
416
|
+
const insight = result;
|
|
417
|
+
return { content: [{ type: 'text', text: `# ${insight.title}\n\n${insight.summary}` }] };
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
return { content: [{ type: 'text', text: `Failed: ${err.message}` }] };
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
// ── Tool: list insights ─────────────────────────────────────────────
|
|
424
|
+
server.tool('list_insights', 'List project AI insights for a selected project scope.', {
|
|
425
|
+
project_id: z.string().describe('The project ID context to list insights for.'),
|
|
426
|
+
kind: z.enum(['onboarding_brief', 'cross_source_correlation', 'architecture_insight']).optional().describe('Filter by insight kind'),
|
|
427
|
+
status: z.enum(['active', 'archived', 'completed']).default('active').describe('Filter by status'),
|
|
428
|
+
limit: z.number().min(1).max(50).default(20).describe('Max number of insights to return'),
|
|
429
|
+
}, async ({ project_id, kind, status, limit }) => {
|
|
430
|
+
try {
|
|
431
|
+
const { insights } = await api.listInsights(project_id, kind, status, limit);
|
|
432
|
+
if (insights.length === 0) {
|
|
433
|
+
return { content: [{ type: 'text', text: `No ${status} insights found.` }] };
|
|
434
|
+
}
|
|
435
|
+
const text = insights.map((insight) => {
|
|
436
|
+
return `## ${insight.title} (${insight.kind})\n**Status:** ${insight.status} · **Created:** ${new Date(insight.created_at).toLocaleString()}\n\n${insight.summary}`;
|
|
437
|
+
}).join('\n---\n\n');
|
|
438
|
+
return { content: [{ type: 'text', text }] };
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
return { content: [{ type: 'text', text: `Failed to list insights: ${err.message}` }] };
|
|
442
|
+
}
|
|
443
|
+
});
|
|
284
444
|
// ── Start ────────────────────────────────────────────────────────────
|
|
285
445
|
const transport = new StdioServerTransport();
|
|
286
446
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowrelay/mcp-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Flow Relay MCP Server for Claude Desktop and Claude Code
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Flow Relay MCP Server for Claude Desktop and Claude Code – handoffs, integrations, and context events via natural conversation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Adriano Sorbello",
|
|
8
8
|
"homepage": "https://www.flowrelay.it",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
|
-
"url": "https://github.com/atrisorb/flow-relay.git"
|
|
11
|
+
"url": "git+https://github.com/atrisorb/flow-relay.git"
|
|
12
12
|
},
|
|
13
13
|
"keywords": [
|
|
14
14
|
"mcp",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"ai"
|
|
20
20
|
],
|
|
21
21
|
"bin": {
|
|
22
|
-
"flowrelay-mcp": "
|
|
22
|
+
"flowrelay-mcp": "dist/index.js"
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
25
|
"dist"
|