feat: mcp first version
This commit is contained in:
parent
7725c1ce71
commit
a271c33f5e
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue