@we-scrum/cli 1.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/LICENSE +21 -0
- package/dist/cli.js +55 -0
- package/package.json +52 -0
- package/src/auth-helper.ts +107 -0
- package/src/cli.ts +102 -0
- package/src/firebase-helper.ts +129 -0
- package/src/mcp-server.ts +127 -0
- package/src/settings-helper.ts +35 -0
- package/src/we-scrum.service.ts +107 -0
- package/tsconfig.json +20 -0
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@we-scrum/cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Cli tool for we-scrum application",
|
|
5
|
+
"main": "dist/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"we-scrum": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"tsup": {
|
|
10
|
+
"entry": [
|
|
11
|
+
"src/cli.ts"
|
|
12
|
+
],
|
|
13
|
+
"format": [
|
|
14
|
+
"cjs"
|
|
15
|
+
],
|
|
16
|
+
"clean": true,
|
|
17
|
+
"minify": true,
|
|
18
|
+
"bundle": true,
|
|
19
|
+
"keepNames": true,
|
|
20
|
+
"noExternal": [
|
|
21
|
+
"@we-scrum/*",
|
|
22
|
+
"@my-devkit/*"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@inquirer/prompts": "^8.3.0",
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
28
|
+
"commander": "^11.1.0",
|
|
29
|
+
"firebase": "12.10.0",
|
|
30
|
+
"google-auth-library": "^10.6.1",
|
|
31
|
+
"open": "^11.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "22.18.6",
|
|
35
|
+
"tsup": "8.5.1",
|
|
36
|
+
"typescript": "5.9.3",
|
|
37
|
+
"@my-devkit/cli": "2.1.0",
|
|
38
|
+
"@my-devkit/core": "1.0.0",
|
|
39
|
+
"@we-scrum/enums": "1.0.0",
|
|
40
|
+
"@we-scrum/models": "1.0.0",
|
|
41
|
+
"@we-scrum/commands": "1.0.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "mdk build",
|
|
45
|
+
"watch": "mdk watch",
|
|
46
|
+
"build:prod": "tsup",
|
|
47
|
+
"publish:public": "pnpm publish --access public",
|
|
48
|
+
"preinstall": "npx only-allow pnpm",
|
|
49
|
+
"global:link": "pnpm link --global",
|
|
50
|
+
"global:unlink": "pnpm remove --global @we-scrum/cli"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Logger } from '@my-devkit/core';
|
|
2
|
+
import { GoogleAuthProvider, signInWithCredential, User } from 'firebase/auth';
|
|
3
|
+
import { CodeChallengeMethod, OAuth2Client } from 'google-auth-library';
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import open from 'open';
|
|
7
|
+
|
|
8
|
+
import { FirebaseHelper } from './firebase-helper';
|
|
9
|
+
import { SettingsHelper } from './settings-helper';
|
|
10
|
+
|
|
11
|
+
export class AuthHelper {
|
|
12
|
+
// 2. Google OAuth2 Config
|
|
13
|
+
private static cid = '847201088225-vi6fq1rjoouoe24ac9oalst4nis0r597.apps.googleusercontent.com';
|
|
14
|
+
private static csec = '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'; // Note: This is a Public Client Secret for a CLI tool, safe to expose per RFC 8252.
|
|
15
|
+
private static PORT = 3000;
|
|
16
|
+
private static REDIRECT_URI = `http://localhost:${this.PORT}`;
|
|
17
|
+
public static get userId(): string {
|
|
18
|
+
return FirebaseHelper.auth.currentUser?.uid;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public static async loginWithGoogle(): Promise<User> {
|
|
22
|
+
const auth = FirebaseHelper.auth;
|
|
23
|
+
|
|
24
|
+
const { verifier, challenge } = this.generatePKCE();
|
|
25
|
+
const oAuth2Client = new OAuth2Client(this.cid, this.csec, this.REDIRECT_URI);
|
|
26
|
+
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
// Create a temporary local server to catch the redirect
|
|
29
|
+
const server = createServer(async (req, res) => {
|
|
30
|
+
try {
|
|
31
|
+
const url = new URL(req.url, this.REDIRECT_URI);
|
|
32
|
+
const code = url.searchParams.get('code');
|
|
33
|
+
|
|
34
|
+
if (code) {
|
|
35
|
+
// Send a friendly message to the browser
|
|
36
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
37
|
+
res.end('<h1>Login Successful!</h1><p>You can close this tab and return to the terminal.</p>');
|
|
38
|
+
|
|
39
|
+
// Stop listening
|
|
40
|
+
server.close();
|
|
41
|
+
|
|
42
|
+
// Exchange the Authorization Code for tokens using the PKCE Verifier
|
|
43
|
+
const { tokens } = await oAuth2Client.getToken({
|
|
44
|
+
code: code,
|
|
45
|
+
codeVerifier: verifier,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Persist credentials in settings
|
|
49
|
+
SettingsHelper.set({ credentials: tokens });
|
|
50
|
+
|
|
51
|
+
// Build Firebase credential and sign in
|
|
52
|
+
const credential = GoogleAuthProvider.credential(tokens.id_token);
|
|
53
|
+
|
|
54
|
+
const userCredential = await signInWithCredential(auth, credential);
|
|
55
|
+
|
|
56
|
+
resolve(userCredential.user);
|
|
57
|
+
}
|
|
58
|
+
} catch (err) {
|
|
59
|
+
res.end('Authentication failed.');
|
|
60
|
+
server.close();
|
|
61
|
+
reject(err);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
server.listen(this.PORT, () => {
|
|
66
|
+
// 4. Generate the Auth URL with the PKCE Challenge
|
|
67
|
+
const authUrl = oAuth2Client.generateAuthUrl({
|
|
68
|
+
access_type: 'offline', // Required to get a refresh_token
|
|
69
|
+
scope: ['openid', 'email', 'profile'],
|
|
70
|
+
code_challenge: challenge,
|
|
71
|
+
code_challenge_method: CodeChallengeMethod.S256,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
Logger.info('Opening your browser for authentication...');
|
|
75
|
+
open(authUrl);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Generates PKCE Verifier and Challenge
|
|
82
|
+
*/
|
|
83
|
+
private static generatePKCE() {
|
|
84
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
85
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
86
|
+
return { verifier, challenge };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public static logout() {
|
|
90
|
+
SettingsHelper.set({ credentials: undefined });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
public static async initializeFromStoredCredentials(): Promise<boolean> {
|
|
94
|
+
const credentials = SettingsHelper.get().credentials ?? null;
|
|
95
|
+
if (!credentials?.id_token) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const auth = FirebaseHelper.auth;
|
|
100
|
+
const credential = GoogleAuthProvider.credential(credentials.id_token);
|
|
101
|
+
await signInWithCredential(auth, credential);
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { select } from '@inquirer/prompts';
|
|
3
|
+
import { Logger } from '@my-devkit/core';
|
|
4
|
+
import { UserProjectModel } from '@we-scrum/models';
|
|
5
|
+
import { Command } from 'commander';
|
|
6
|
+
|
|
7
|
+
import { AuthHelper } from './auth-helper';
|
|
8
|
+
import { FirebaseHelper } from './firebase-helper';
|
|
9
|
+
import { startMcpServer } from './mcp-server';
|
|
10
|
+
import { SettingsHelper } from './settings-helper';
|
|
11
|
+
|
|
12
|
+
Logger.registerLogger(new Logger.ConsoleImplementation());
|
|
13
|
+
|
|
14
|
+
const program = new Command();
|
|
15
|
+
|
|
16
|
+
program.name('we-scrum').description('CLI for we-scrum application').version('1.0.0');
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command('login')
|
|
20
|
+
.description('Authenticate with your Google account')
|
|
21
|
+
.action(async () => {
|
|
22
|
+
try {
|
|
23
|
+
await AuthHelper.loginWithGoogle();
|
|
24
|
+
process.exit(0);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
Logger.error(error?.message ?? 'Login failed');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
program
|
|
32
|
+
.command('logout')
|
|
33
|
+
.description('Log out and remove stored credentials')
|
|
34
|
+
.action(() => {
|
|
35
|
+
AuthHelper.logout();
|
|
36
|
+
Logger.info('Logged out successfully.');
|
|
37
|
+
process.exit(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
program
|
|
41
|
+
.command('use-project')
|
|
42
|
+
.description('Select the active project')
|
|
43
|
+
.option('--projectId <id>', 'Set the active project directly without the interactive prompt')
|
|
44
|
+
.action(async (options: { projectId?: string }) => {
|
|
45
|
+
try {
|
|
46
|
+
const initialized = await AuthHelper.initializeFromStoredCredentials();
|
|
47
|
+
if (!initialized) {
|
|
48
|
+
Logger.error('Not authenticated. Please run "we-scrum login" first.');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const projects = await FirebaseHelper.getCollection<UserProjectModel>(`users/${AuthHelper.userId}/projects`);
|
|
53
|
+
if (projects.length === 0) {
|
|
54
|
+
Logger.error('No projects found for your account.');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let selectedProjectId: string;
|
|
59
|
+
|
|
60
|
+
if (options.projectId) {
|
|
61
|
+
const match = projects.find((p) => p.projectId === options.projectId);
|
|
62
|
+
if (!match) {
|
|
63
|
+
Logger.error(`Project "${options.projectId}" not found in your account.`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
selectedProjectId = match.projectId;
|
|
67
|
+
} else {
|
|
68
|
+
selectedProjectId = await select({
|
|
69
|
+
message: 'Select a project',
|
|
70
|
+
default: SettingsHelper.get().selectedProjectId,
|
|
71
|
+
choices: projects.map((p) => ({
|
|
72
|
+
name: p.name,
|
|
73
|
+
value: p.projectId,
|
|
74
|
+
description: p.projectId,
|
|
75
|
+
})),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
SettingsHelper.set({ selectedProjectId });
|
|
80
|
+
Logger.info(`Active project set to: ${projects.find((p) => p.projectId === selectedProjectId)?.name}`);
|
|
81
|
+
process.exit(0);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error?.name === 'ExitPromptError') {
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
Logger.error(error?.message ?? 'Failed to select project');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
program
|
|
92
|
+
.command('mcp')
|
|
93
|
+
.description('Start the we-scrum MCP server (stdio transport)')
|
|
94
|
+
.option('--projectId <id>', 'Override the active project for this session')
|
|
95
|
+
.action(async (options: { projectId?: string }) => {
|
|
96
|
+
if (options.projectId) {
|
|
97
|
+
SettingsHelper.set({ selectedProjectId: options.projectId });
|
|
98
|
+
}
|
|
99
|
+
await startMcpServer();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { deserialize } from '@my-devkit/core';
|
|
2
|
+
import * as models from '@we-scrum/models';
|
|
3
|
+
import { FirebaseApp, initializeApp } from 'firebase/app';
|
|
4
|
+
import { Auth, getAuth } from 'firebase/auth';
|
|
5
|
+
import {
|
|
6
|
+
DocumentData,
|
|
7
|
+
FieldPath,
|
|
8
|
+
Firestore,
|
|
9
|
+
OrderByDirection,
|
|
10
|
+
Query,
|
|
11
|
+
QueryCompositeFilterConstraint,
|
|
12
|
+
QueryNonFilterConstraint,
|
|
13
|
+
WhereFilterOp,
|
|
14
|
+
and,
|
|
15
|
+
collection,
|
|
16
|
+
doc,
|
|
17
|
+
getDoc,
|
|
18
|
+
getDocs,
|
|
19
|
+
getFirestore,
|
|
20
|
+
limit,
|
|
21
|
+
orderBy,
|
|
22
|
+
query,
|
|
23
|
+
where,
|
|
24
|
+
} from 'firebase/firestore';
|
|
25
|
+
|
|
26
|
+
for (const i in models) {
|
|
27
|
+
new models[i]();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class FirebaseHelper {
|
|
31
|
+
private static _app: FirebaseApp;
|
|
32
|
+
private static _auth: Auth;
|
|
33
|
+
private static _firestore: Firestore;
|
|
34
|
+
|
|
35
|
+
public static get auth(): Auth {
|
|
36
|
+
if (!this._auth) {
|
|
37
|
+
this._auth = getAuth(FirebaseHelper.app);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return this._auth;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public static async getCollection<T>(path: string, options?: FirebaseHelper.Query): Promise<T[]> {
|
|
44
|
+
const q = this.query(path, options);
|
|
45
|
+
|
|
46
|
+
const documents = await getDocs(q);
|
|
47
|
+
|
|
48
|
+
return documents.docs.map((doc) => deserialize<T>(doc.data()));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public static async findDocument<T>(path: string, where: FirebaseHelper.Where[]): Promise<T> {
|
|
52
|
+
const documents = await this.getCollection<T>(path, { where });
|
|
53
|
+
|
|
54
|
+
if (documents.length === 0) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (documents.length > 1) {
|
|
59
|
+
throw new Error(`More than one document found at ${path} where ${where.map((w) => JSON.stringify(w)).join(' AND ')}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return documents[0];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public static async getDocument<T>(path: string): Promise<T | null> {
|
|
66
|
+
const docRef = doc(this.firestore, path);
|
|
67
|
+
const docSnapshot = await getDoc(docRef);
|
|
68
|
+
|
|
69
|
+
if (!docSnapshot.exists()) {
|
|
70
|
+
throw new Error(`Document ${path} not found`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return deserialize<T>(docSnapshot.data());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private static get app() {
|
|
77
|
+
if (!this._app) {
|
|
78
|
+
// Should depend on targeted environment
|
|
79
|
+
const firebaseConfig = {
|
|
80
|
+
apiKey: 'AIzaSyCodItNw1wtWfx1GAU_x0Pj96mCAUyrWvU',
|
|
81
|
+
authDomain: 'we-scrum-prod.firebaseapp.com',
|
|
82
|
+
projectId: 'we-scrum-prod',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
this._app = initializeApp(firebaseConfig);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return this._app;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private static get firestore() {
|
|
92
|
+
if (!this._firestore) {
|
|
93
|
+
this._firestore = getFirestore(this.app);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return this._firestore;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private static query(path: string, options?: FirebaseHelper.Query): Query<DocumentData, DocumentData> {
|
|
100
|
+
const ref = collection(this.firestore, path);
|
|
101
|
+
|
|
102
|
+
let filter: QueryCompositeFilterConstraint;
|
|
103
|
+
if (options?.where) {
|
|
104
|
+
filter = and(...options.where.map((w) => where(...w)));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const constraints: QueryNonFilterConstraint[] = [];
|
|
108
|
+
if (options?.orderBy) {
|
|
109
|
+
constraints.push(orderBy(...options.orderBy));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (options?.limit) {
|
|
113
|
+
constraints.push(limit(options.limit));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return query(ref, filter, ...constraints);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export namespace FirebaseHelper {
|
|
121
|
+
export interface Query {
|
|
122
|
+
where?: Where[];
|
|
123
|
+
orderBy?: OrderBy;
|
|
124
|
+
limit?: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type Where = [string | FieldPath, opStr: WhereFilterOp, value: unknown];
|
|
128
|
+
type OrderBy = [string | FieldPath, OrderByDirection?];
|
|
129
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { ProgressStatus } from '@we-scrum/enums';
|
|
5
|
+
import { ProjectStoryModel, ProjectStorySectionModel } from '@we-scrum/models';
|
|
6
|
+
|
|
7
|
+
import { AuthHelper } from './auth-helper';
|
|
8
|
+
import { FirebaseHelper } from './firebase-helper';
|
|
9
|
+
import { SettingsHelper } from './settings-helper';
|
|
10
|
+
|
|
11
|
+
const TOOLS = [
|
|
12
|
+
{
|
|
13
|
+
name: 'get_next_task',
|
|
14
|
+
description: `Get the next pending task (status: ToDo) for a story.
|
|
15
|
+
Returns the task description, the task type template (development guidelines),
|
|
16
|
+
and the full section analysis specifying what must be implemented
|
|
17
|
+
(route changes, object/DTO changes, enumeration changes, relation/handler changes).
|
|
18
|
+
Also returns storyId, sectionId and taskId needed to call start_task and complete_task.`,
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
identificationNumber: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'The story identification number, e.g. "144154"',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
required: ['identificationNumber'],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
async function handleGetNextTask(identificationNumber: string): Promise<string> {
|
|
33
|
+
const projectId = SettingsHelper.get().selectedProjectId;
|
|
34
|
+
if (!projectId) {
|
|
35
|
+
throw new Error('No active project selected. Please run "we-scrum use-project" first.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 1. Find story by identificationNumber
|
|
39
|
+
const story = await FirebaseHelper.findDocument<ProjectStoryModel>(`/projects/${projectId}/stories`, [
|
|
40
|
+
['identificationNumber', '==', identificationNumber],
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
if (!story) {
|
|
44
|
+
throw new Error(`Story "${identificationNumber}" not found in the active project.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. Fetch all section documents at once
|
|
48
|
+
const sections = await FirebaseHelper.getCollection<ProjectStorySectionModel>(
|
|
49
|
+
`projects/${projectId}/stories/${story.storyId}/sections`,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// 3. Order sections using the position stored in story.sections[]
|
|
53
|
+
const positionBySection = new Map(story.sections.map((s) => [s.sectionId, s.position ?? 0]));
|
|
54
|
+
const sortedSections = sections.sort((a, b) => (positionBySection.get(a.sectionId) ?? 0) - (positionBySection.get(b.sectionId) ?? 0));
|
|
55
|
+
|
|
56
|
+
// 4. Walk sections in order and find the first ToDo task
|
|
57
|
+
for (const section of sortedSections) {
|
|
58
|
+
const tasks = [...(section.tasks ?? [])].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
|
59
|
+
const nextTask = tasks.find((t) => t.progress?.status === ProgressStatus.ToDo);
|
|
60
|
+
|
|
61
|
+
if (!nextTask) continue;
|
|
62
|
+
|
|
63
|
+
const result = {
|
|
64
|
+
storyId: story.storyId,
|
|
65
|
+
storyName: story.name,
|
|
66
|
+
sectionId: section.sectionId,
|
|
67
|
+
sectionName: section.name,
|
|
68
|
+
task: {
|
|
69
|
+
taskId: nextTask.taskId,
|
|
70
|
+
description: nextTask.description,
|
|
71
|
+
taskTypeName: nextTask.taskTypeName,
|
|
72
|
+
position: nextTask.position,
|
|
73
|
+
status: nextTask.progress?.status,
|
|
74
|
+
},
|
|
75
|
+
sectionAnalysis: {
|
|
76
|
+
...(section.routeChanges?.length && { routeChanges: section.routeChanges }),
|
|
77
|
+
...(section.objectChanges?.length && { objectChanges: section.objectChanges }),
|
|
78
|
+
...(section.enumerationChanges?.length && { enumerationChanges: section.enumerationChanges }),
|
|
79
|
+
...(section.relationChanges?.length && { relationChanges: section.relationChanges }),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return JSON.stringify(result, null, 2);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return JSON.stringify({ message: `All tasks for story "${identificationNumber}" are complete.` });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function startMcpServer(): Promise<void> {
|
|
90
|
+
const initialized = await AuthHelper.initializeFromStoredCredentials();
|
|
91
|
+
if (!initialized) {
|
|
92
|
+
process.stderr.write('Error: Not authenticated. Please run "we-scrum login" first.\n');
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const server = new Server({ name: 'we-scrum-mcp-server', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
97
|
+
|
|
98
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
99
|
+
|
|
100
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
101
|
+
const { name, arguments: args } = request.params;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
let text: string;
|
|
105
|
+
|
|
106
|
+
switch (name) {
|
|
107
|
+
case 'get_next_task':
|
|
108
|
+
text = await handleGetNextTask(args.identificationNumber as string);
|
|
109
|
+
break;
|
|
110
|
+
default:
|
|
111
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { content: [{ type: 'text', text }] };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return {
|
|
117
|
+
content: [{ type: 'text', text: JSON.stringify({ error: error.message }) }],
|
|
118
|
+
isError: true,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const transport = new StdioServerTransport();
|
|
124
|
+
await server.connect(transport);
|
|
125
|
+
|
|
126
|
+
process.stderr.write('we-scrum MCP server running.\n');
|
|
127
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { JsonHelper } from '@my-devkit/core';
|
|
2
|
+
import { Credentials } from 'google-auth-library';
|
|
3
|
+
import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join as pathJoin } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const SETTINGS_FILE = pathJoin(homedir(), '.we-scrum.json');
|
|
8
|
+
|
|
9
|
+
export interface WeScrumSettings {
|
|
10
|
+
credentials?: Credentials;
|
|
11
|
+
selectedProjectId?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class SettingsHelper {
|
|
15
|
+
public static get(): WeScrumSettings {
|
|
16
|
+
if (!existsSync(SETTINGS_FILE)) {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
const content = readFileSync(SETTINGS_FILE, 'utf8');
|
|
20
|
+
return JsonHelper.parse<WeScrumSettings>(content) ?? {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public static set(partial: Partial<WeScrumSettings>): void {
|
|
24
|
+
const updated = { ...this.get(), ...partial };
|
|
25
|
+
|
|
26
|
+
writeFileSync(SETTINGS_FILE, JSON.stringify(updated, null, 2));
|
|
27
|
+
chmodSync(SETTINGS_FILE, 0o600);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public static clear(): void {
|
|
31
|
+
if (existsSync(SETTINGS_FILE)) {
|
|
32
|
+
rmSync(SETTINGS_FILE);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Command, serialize } from '@my-devkit/core';
|
|
2
|
+
|
|
3
|
+
export class WeScrumService {
|
|
4
|
+
constructor(
|
|
5
|
+
private backendUrl: string,
|
|
6
|
+
private projectId: string,
|
|
7
|
+
private userId: string,
|
|
8
|
+
) {}
|
|
9
|
+
|
|
10
|
+
// public async createEnumeration(enumeration: Enumeration): Promise<string> {
|
|
11
|
+
// Logger.info(`Creating enumeration ${enumeration.name}`);
|
|
12
|
+
|
|
13
|
+
// const createCommand = TypeHelper.transform(CreateEnumerationCommand, {
|
|
14
|
+
// projectId: this.projectId,
|
|
15
|
+
// name: enumeration.name,
|
|
16
|
+
// });
|
|
17
|
+
|
|
18
|
+
// const enumerationId = await this.post<CreateEnumerationCommand, string>('enumeration-management/create-enumeration', createCommand);
|
|
19
|
+
// for (const property of enumeration.properties) {
|
|
20
|
+
// const propertyCommand = TypeHelper.transform(CreateEnumerationPropertyCommand, {
|
|
21
|
+
// enumerationId,
|
|
22
|
+
// enumerationPropertyId: guid(),
|
|
23
|
+
// name: property.label,
|
|
24
|
+
// value: property.value,
|
|
25
|
+
// });
|
|
26
|
+
// await this.post<CreateEnumerationPropertyCommand, string>(
|
|
27
|
+
// 'enumeration-management/create-enumeration-property',
|
|
28
|
+
// propertyCommand,
|
|
29
|
+
// );
|
|
30
|
+
// }
|
|
31
|
+
|
|
32
|
+
// return enumerationId;
|
|
33
|
+
// }
|
|
34
|
+
|
|
35
|
+
// public async createObject(object: Object, getEnumerationId: (name: string) => string): Promise<string> {
|
|
36
|
+
// Logger.info(`Creating object ${object.name}`);
|
|
37
|
+
|
|
38
|
+
// const createCommand = TypeHelper.transform(CreateObjectCommand, {
|
|
39
|
+
// projectId: this.projectId,
|
|
40
|
+
// name: object.name,
|
|
41
|
+
// });
|
|
42
|
+
// const objectId = await this.post<CreateObjectCommand, string>('object-management/create-object', createCommand);
|
|
43
|
+
|
|
44
|
+
// const propertyMap = new Map<string, string>();
|
|
45
|
+
// for (const property of object.properties) {
|
|
46
|
+
// const objectPropertyId = guid();
|
|
47
|
+
// const propertyCommand = TypeHelper.transform(CreateObjectPropertyCommand, {
|
|
48
|
+
// objectId,
|
|
49
|
+
// objectPropertyId,
|
|
50
|
+
// objectPropertyParentId: propertyMap.get(property.parent) || null,
|
|
51
|
+
// name: property.name,
|
|
52
|
+
// objectPropertyType: property.type,
|
|
53
|
+
// isArray: property.isArray,
|
|
54
|
+
// enumerationId: property.enumerationName ? getEnumerationId(property.enumerationName) : null,
|
|
55
|
+
// });
|
|
56
|
+
// await this.post<CreateObjectPropertyCommand, string>('object-management/create-object-property', propertyCommand);
|
|
57
|
+
|
|
58
|
+
// propertyMap.set(property.path, objectPropertyId);
|
|
59
|
+
// }
|
|
60
|
+
|
|
61
|
+
// return objectId;
|
|
62
|
+
// }
|
|
63
|
+
|
|
64
|
+
// public async createHandler(handler: Handler, getObjectId: (name: string) => string): Promise<void> {
|
|
65
|
+
// Logger.info(`Creating handler ${handler.name}`);
|
|
66
|
+
|
|
67
|
+
// for (const relation of handler.relations) {
|
|
68
|
+
// const createCommand = TypeHelper.transform(CreateRelationCommand, {
|
|
69
|
+
// projectId: this.projectId,
|
|
70
|
+
// boundedContext: handler.boundedContext,
|
|
71
|
+
// handlerName: handler.name,
|
|
72
|
+
// objectIds: relation.map((o) => getObjectId(o)),
|
|
73
|
+
// subscriptionName: null,
|
|
74
|
+
// });
|
|
75
|
+
// await this.post<CreateRelationCommand, string>('relation-management/create-relation', createCommand);
|
|
76
|
+
// }
|
|
77
|
+
// }
|
|
78
|
+
|
|
79
|
+
// public async createRoute(route: Route, getObjectId: (name: string) => string): Promise<string> {
|
|
80
|
+
// Logger.info(`Creating route ${route.method} ${route.path}`);
|
|
81
|
+
|
|
82
|
+
// const createCommand = TypeHelper.transform(CreateRouteCommand, {
|
|
83
|
+
// projectId: this.projectId,
|
|
84
|
+
// method: route.method,
|
|
85
|
+
// path: route.path,
|
|
86
|
+
// objectId: getObjectId(route.object),
|
|
87
|
+
// permission: null,
|
|
88
|
+
// });
|
|
89
|
+
|
|
90
|
+
// return this.post<CreateRouteCommand, string>('route-management/create-route', createCommand);
|
|
91
|
+
// }
|
|
92
|
+
|
|
93
|
+
private async post<C extends Command, R>(route: string, command: C): Promise<R> {
|
|
94
|
+
const response = await fetch(`${this.backendUrl}/command/${route}`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
body: JSON.stringify(serialize(command)),
|
|
97
|
+
headers: {
|
|
98
|
+
'Content-Type': 'application/json',
|
|
99
|
+
Authorization: 'Basic ' + Buffer.from(`${this.userId}:20V0anV4M!we`).toString('base64'),
|
|
100
|
+
},
|
|
101
|
+
signal: AbortSignal.timeout(60 * 1000),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const body = (await response.json()) as { result: R };
|
|
105
|
+
return body.result;
|
|
106
|
+
}
|
|
107
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "commonjs",
|
|
4
|
+
"moduleResolution": "node",
|
|
5
|
+
"target": "es2021",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"baseUrl": "src",
|
|
8
|
+
"sourceMap": true,
|
|
9
|
+
"declaration": false,
|
|
10
|
+
"emitDecoratorMetadata": true,
|
|
11
|
+
"experimentalDecorators": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"paths": {
|
|
15
|
+
"@modelcontextprotocol/sdk/server": ["../node_modules/@modelcontextprotocol/sdk/dist/esm/server/index"],
|
|
16
|
+
"@modelcontextprotocol/sdk/server/stdio.js": ["../node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio"],
|
|
17
|
+
"@modelcontextprotocol/sdk/types.js": ["../node_modules/@modelcontextprotocol/sdk/dist/esm/types"]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|