feat: mcp first version

This commit is contained in:
Nevo David 2025-04-12 21:13:37 +07:00
parent 7725c1ce71
commit a271c33f5e
15 changed files with 2190 additions and 2653 deletions

View File

@ -31,6 +31,9 @@ import { Nowpayments } from '@gitroom/nestjs-libraries/crypto/nowpayments';
import { WebhookController } from '@gitroom/backend/api/routes/webhooks.controller';
import { SignatureController } from '@gitroom/backend/api/routes/signature.controller';
import { AutopostController } from '@gitroom/backend/api/routes/autopost.controller';
import { McpService } from '@gitroom/nestjs-libraries/mcp/mcp.service';
import { McpController } from '@gitroom/backend/api/routes/mcp.controller';
import { McpSettings } from '@gitroom/nestjs-libraries/mcp/mcp.settings';
const authenticatedController = [
UsersController,
@ -56,6 +59,7 @@ const authenticatedController = [
StripeController,
AuthController,
PublicController,
McpController,
...authenticatedController,
],
providers: [
@ -71,6 +75,7 @@ const authenticatedController = [
TrackService,
ShortLinkService,
Nowpayments,
McpService
],
get exports() {
return [...this.imports, ...this.providers];

View File

@ -0,0 +1,33 @@
import { Body, Controller, HttpException, Param, Post, Sse } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { McpService } from '@gitroom/nestjs-libraries/mcp/mcp.service';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
@ApiTags('Mcp')
@Controller('/mcp')
export class McpController {
constructor(
private _mcpService: McpService,
private _organizationService: OrganizationService
) {}
@Sse('/:api/sse')
async sse(@Param('api') api: string) {
const apiModel = await this._organizationService.getOrgByApiKey(api);
if (!apiModel) {
throw new HttpException('Invalid url', 400);
}
return await this._mcpService.runServer(api, apiModel.id);
}
@Post('/:api/messages')
async post(@Param('api') api: string, @Body() body: any) {
const apiModel = await this._organizationService.getOrgByApiKey(api);
if (!apiModel) {
throw new HttpException('Invalid url', 400);
}
return this._mcpService.processPostBody(apiModel.id, body);
}
}

View File

@ -10,6 +10,7 @@ import { PublicApiModule } from '@gitroom/backend/public-api/public.api.module';
import { ThrottlerBehindProxyGuard } from '@gitroom/nestjs-libraries/throttler/throttler.provider';
import { ThrottlerModule } from '@nestjs/throttler';
import { AgentModule } from '@gitroom/nestjs-libraries/agent/agent.module';
import { McpModule } from '@gitroom/backend/mcp/mcp.module';
@Global()
@Module({
@ -20,6 +21,7 @@ import { AgentModule } from '@gitroom/nestjs-libraries/agent/agent.module';
PluginModule,
PublicApiModule,
AgentModule,
McpModule,
ThrottlerModule.forRoot([
{
ttl: 3600000,

View File

@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { McpTool } from '@gitroom/nestjs-libraries/mcp/mcp.tool';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { string, array, enum as eenum, object } from 'zod';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import dayjs from 'dayjs';
@Injectable()
export class MainMcp {
constructor(
private _integrationService: IntegrationService,
private _postsService: PostsService
) {}
@McpTool({ toolName: 'POSTIZ_CONFIGURATION_PRERUN' })
async preRun() {
return [{type: 'text', text: `Today date is ${dayjs.utc().format()}`}];
}
@McpTool({ toolName: 'POSTIZ_PROVIDERS_LIST' })
async listOfProviders(organization: string) {
const list = (
await this._integrationService.getIntegrationsList(organization)
).map((org) => ({
id: org.id,
name: org.name,
identifier: org.providerIdentifier,
picture: org.picture,
disabled: org.disabled,
profile: org.profile,
customer: org.customer
? {
id: org.customer.id,
name: org.customer.name,
}
: undefined,
}));
return [{ type: 'text', text: JSON.stringify(list) }];
}
@McpTool({
toolName: 'POSTIZ_SCHEDULE_POST',
zod: {
type: eenum(['draft', 'scheduled']),
date: string().describe('UTC TIME'),
providerId: string().describe('Use POSTIZ_PROVIDERS_LIST to get the id'),
posts: array(object({ text: string(), images: array(string()) })),
},
})
async schedulePost(
organization: string,
obj: {
type: 'draft' | 'schedule';
date: string;
providerId: string;
posts: { text: string; images: string[] }[];
}
) {
const create = await this._postsService.createPost(organization, {
date: obj.date,
type: obj.type,
tags: [],
posts: [
{
group: makeId(10),
value: obj.posts.map((post) => ({
content: post.text,
id: makeId(10),
image: post.images.map((image) => ({
id: image,
path: image,
})),
})),
// @ts-ignore
settings: {},
integration: {
id: obj.providerId,
},
},
],
});
return [
{
type: 'text',
text: `Post created successfully, check it here: ${process.env.FRONTEND_URL}/p/${create[0].postId}`,
},
];
}
}

View File

@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { MainMcp } from '@gitroom/backend/mcp/main.mcp';
@Global()
@Module({
imports: [],
controllers: [],
providers: [MainMcp],
get exports() {
return [...this.providers];
},
})
export class McpModule {}

View File

@ -2,5 +2,9 @@ import { ReactNode } from 'react';
import { PreviewWrapper } from '@gitroom/frontend/components/preview/preview.wrapper';
export default async function AppLayout({ children }: { children: ReactNode }) {
return <PreviewWrapper>{children}</PreviewWrapper>;
return (
<div className="bg-[#000000] min-h-screen">
<PreviewWrapper>{children}</PreviewWrapper>
</div>
);
}

View File

@ -5,11 +5,14 @@ import { useUser } from '../layout/user.context';
import { Button } from '@gitroom/react/form/button';
import copy from 'copy-to-clipboard';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { useVariables } from '@gitroom/react/helpers/variable.context';
export const PublicComponent = () => {
const user = useUser();
const {frontEndUrl} = useVariables();
const toaster = useToaster();
const [reveal, setReveal] = useState(false);
const [reveal2, setReveal2] = useState(false);
const copyToClipboard = useCallback(() => {
toaster.show('API Key copied to clipboard', 'success');
@ -53,6 +56,30 @@ export const PublicComponent = () => {
)}
</div>
</div>
<h3 className="text-[20px]">MCP</h3>
<div className="text-customColor18 mt-[4px]">
Connect your MCP client to Postiz to schedule your posts faster!
</div>
<div className="my-[16px] mt-[16px] bg-sixth border-fifth items-center border rounded-[4px] p-[24px] flex gap-[24px]">
<div className="flex items-center">
{reveal2 ? (
`${frontEndUrl}/mcp/` + user.publicApi + '/sse'
) : (
<>
<div className="blur-sm">{(`${frontEndUrl}/mcp/` + user.publicApi + '/sse').slice(0, -5)}</div>
<div>{(`${frontEndUrl}/mcp/` + user.publicApi + '/sse').slice(-5)}</div>
</>
)}
</div>
<div>
{!reveal2 ? (
<Button onClick={() => setReveal2(true)}>Reveal</Button>
) : (
<Button onClick={copyToClipboard}>Copy Key</Button>
)}
</div>
</div>
</div>
);
};

View File

@ -27,7 +27,7 @@ import { FarcasterProvider } from '@gitroom/nestjs-libraries/integrations/social
import { TelegramProvider } from '@gitroom/nestjs-libraries/integrations/social/telegram.provider';
import { NostrProvider } from '@gitroom/nestjs-libraries/integrations/social/nostr.provider';
const socialIntegrationList: SocialProvider[] = [
export const socialIntegrationList: SocialProvider[] = [
new XProvider(),
new LinkedinProvider(),
new LinkedinPageProvider(),

View File

@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import EventEmitter from 'events';
import { finalize, fromEvent, startWith } from 'rxjs';
import { McpTransport } from '@gitroom/nestjs-libraries/mcp/mcp.transport';
import { JSONRPCMessageSchema } from '@gitroom/nestjs-libraries/mcp/mcp.types';
import { McpSettings } from '@gitroom/nestjs-libraries/mcp/mcp.settings';
import { MainMcp } from '@gitroom/backend/mcp/main.mcp';
@Injectable()
export class McpService {
static event = new EventEmitter();
constructor(
private _mainMcp: MainMcp
) {
}
async runServer(apiKey: string, organization: string) {
const server = McpSettings.load(organization, this._mainMcp).server();
const transport = new McpTransport(organization);
const observer = fromEvent(
McpService.event,
`organization-${organization}`
).pipe(
startWith({
type: 'endpoint',
data: process.env.NEXT_PUBLIC_BACKEND_URL + '/mcp/' + apiKey + '/messages',
}),
finalize(() => {
transport.close();
})
);
console.log('MCP transport started');
await server.connect(transport);
return observer;
}
async processPostBody(organization: string, body: object) {
const server = McpSettings.load(organization, this._mainMcp).server();
const message = JSONRPCMessageSchema.parse(body);
const transport = new McpTransport(organization);
await server.connect(transport);
transport.handlePostMessage(message);
return {};
}
}

View File

@ -0,0 +1,80 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { MainMcp } from '@gitroom/backend/mcp/main.mcp';
import { socialIntegrationList } from '@gitroom/nestjs-libraries/integrations/integration.manager';
export class McpSettings {
static singleton: McpSettings;
private _server: McpServer;
createServer(organization: string, service: MainMcp) {
this._server = new McpServer(
{
name: 'Postiz',
version: '2.0.0',
},
{
instructions: `Postiz is a service to schedule social media posts for ${socialIntegrationList
.map((p) => p.name)
.join(
', '
)} to schedule you need to have the providerId (you can get it from POSTIZ_PROVIDERS_LIST), user need to specify the schedule date (or now), text, you also can send base64 images and text for the comments. When you get POSTIZ_PROVIDERS_LIST, always display all the options to the user`,
}
);
for (const usePrompt of Reflect.getMetadata(
'MCP_PROMPT',
MainMcp.prototype
) || []) {
const list = [
usePrompt.data.promptName,
usePrompt.data.zod,
async (...args: any[]) => {
return {
// @ts-ignore
messages: await service[usePrompt.func as string](
organization,
...args
),
};
},
].filter((f) => f);
this._server.prompt(...(list as [any, any, any]));
}
for (const usePrompt of Reflect.getMetadata(
'MCP_TOOL',
MainMcp.prototype
) || []) {
const list: any[] = [
usePrompt.data.toolName,
usePrompt.data.zod,
async (...args: any[]) => {
return {
// @ts-ignore
content: await service[usePrompt.func as string](
organization,
...args
),
};
},
].filter((f) => f);
this._server.tool(...(list as [any, any, any]));
}
return this;
}
server() {
return this._server;
}
static load(organization: string, service: MainMcp): McpSettings {
if (!McpSettings.singleton) {
McpSettings.singleton = new McpSettings().createServer(
organization,
service
);
}
return McpSettings.singleton;
}
}

View File

@ -0,0 +1,25 @@
import { ZodRawShape } from 'zod';
export function McpTool (params: {toolName: string, zod?: ZodRawShape}) {
return function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const existingMetadata = Reflect.getMetadata('MCP_TOOL', target) || [];
// Add the metadata information for this method
existingMetadata.push({ data: params, func: propertyKey });
// Define metadata on the class prototype (so it can be retrieved from the class)
Reflect.defineMetadata('MCP_TOOL', existingMetadata, target);
}
}
export function McpPrompt (params: {promptName: string, zod?: ZodRawShape}) {
return function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const existingMetadata = Reflect.getMetadata('MCP_PROMPT', target) || [];
// Add the metadata information for this method
existingMetadata.push({ data: params, func: propertyKey });
// Define metadata on the class prototype (so it can be retrieved from the class)
Reflect.defineMetadata('MCP_PROMPT', existingMetadata, target);
}
}

View File

@ -0,0 +1,43 @@
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { McpService } from '@gitroom/nestjs-libraries/mcp/mcp.service';
import { JSONRPCMessage, JSONRPCMessageSchema } from '@gitroom/nestjs-libraries/mcp/mcp.types';
export class McpTransport implements Transport {
constructor(private _organization: string) {}
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
async start() {
}
async send(message: JSONRPCMessage): Promise<void> {
McpService.event.emit(`organization-${this._organization}`, {
type: 'message',
data: JSON.stringify(message),
});
}
async close() {
console.log('MCP transport closed');
McpService.event.removeAllListeners(`organization-${this._organization}`);
}
handlePostMessage(message: any) {
let parsedMessage: JSONRPCMessage;
try {
parsedMessage = JSONRPCMessageSchema.parse(message);
} catch (error) {
this.onerror?.(error as Error);
throw error;
}
this.onmessage?.(parsedMessage);
}
get sessionId() {
return this._organization;
}
}

File diff suppressed because it is too large Load Diff

3186
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -51,6 +51,7 @@
"@mantine/dates": "^5.10.5",
"@mantine/hooks": "^5.10.5",
"@mantine/modals": "^5.10.5",
"@modelcontextprotocol/sdk": "^1.9.0",
"@nestjs/common": "^10.0.2",
"@nestjs/core": "^10.0.2",
"@nestjs/microservices": "^10.3.1",