Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.4.0] — 2026-04-02

### Added

- **MCP server** (`@placet/mcp`) — Model Context Protocol server for AI coding agents (Claude Code, Copilot, Cursor, Windsurf) with StreamableHTTP and stdio transports, 13 tools (send_message, get_messages, get_message, delete_message, send_review_message, wait_for_review, get_pending_reviews, list_channels, create_channel, ping_status, list_plugins + dynamic plugin tools)
- Individual docs pages for each connection type (MCP, WebSocket, REST API, Webhooks)
- n8n community nodes package (`n8n-nodes-placet`)

### Changed

- **API authentication switched to `x-api-key` header** — all agent endpoints now use `x-api-key: hp_...` instead of `Authorization: Bearer`
- WebSocket gateway supports both `auth.apiKey` (agents) and `auth.token` (frontend JWT)
- OpenAPI spec auto-generated with `api-key` security scheme and 6 logical endpoint groups (Agents, Messages, Reviews, Files, Status, Plugins)
- Documentation restructured — API Reference with grouped REST endpoints + WebSocket page, Connection Types as own category, Integrations renamed to Examples
- All examples, integration docs, and curl snippets updated for `x-api-key`
- E2e tests updated to use `x-api-key` header

## [0.3.0] — 2026-03-31

### Added
Expand Down
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This document describes how to set up Placet for local development and outlines

```bash
# Clone the repository
git clone https://github.com/centerbitco/placet.git
git clone https://github.com/placet-io/placet.git
cd placet

# Install all dependencies
Expand Down
26 changes: 24 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@
# make validate — Run lint + format check + build across all packages
# make test — Run unit + e2e tests
# make lint — Run lint only
# make inspect-mcp — Launch MCP Inspector to debug the MCP server
# make export-openapi — Export OpenAPI spec from backend to docs/
# make docs-dev — Start Mintlify docs dev server
# make logs — Tail backend logs
# make clean — Remove volumes + containers + node_modules
# ─────────────────────────────────────────────────────────────────────────────

.PHONY: setup start stop update validate validate-plugin test lint test-unit test-e2e \
build logs clean reset db-push db-migrate export-openapi docs-dev help
build logs clean reset db-push db-migrate export-openapi docs-dev inspect-mcp help

SHELL := /bin/bash

Expand Down Expand Up @@ -101,13 +102,34 @@ lint: ## Run lint across all packages
test: test-unit test-e2e ## Run all tests (unit + e2e)

test-unit: ## Run unit tests
@echo "══ Unit tests ══"
@echo "══ Backend unit tests ══"
npm test --workspace=@placet/backend
@echo "══ MCP server unit tests ══"
npm test --workspace=@placet/mcp

test-e2e: ## Run e2e tests
@echo "══ E2E tests ══"
npm run test:e2e --workspace=@placet/backend

# ── MCP ────────────────────────────────────────────────────────────────────

inspect-mcp: ## Launch MCP Inspector UI to debug the Placet MCP server (stdio)
@if [ -z "$$PLACET_API_URL" ] || [ -z "$$PLACET_API_KEY" ]; then \
echo "Usage: PLACET_API_URL=http://localhost:3001 PLACET_API_KEY=hp_... make inspect-mcp"; \
echo ""; \
echo " Required env vars:"; \
echo " PLACET_API_URL — Backend URL (e.g. http://localhost:3001)"; \
echo " PLACET_API_KEY — API key starting with hp_"; \
echo " Optional env vars:"; \
echo " PLACET_DEFAULT_CHANNEL — Default channel/agent ID"; \
exit 1; \
fi
npx @modelcontextprotocol/inspector \
-e PLACET_API_URL=$$PLACET_API_URL \
-e PLACET_API_KEY=$$PLACET_API_KEY \
$$([ -n "$$PLACET_DEFAULT_CHANNEL" ] && echo "-e PLACET_DEFAULT_CHANNEL=$$PLACET_DEFAULT_CHANNEL") \
node packages/mcp-server/dist/index.js --stdio

# ── Database ───────────────────────────────────────────────────────────────

db-push: ## Push Prisma schema to database (dev/setup)
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ AI agents are getting more capable every day, but they still need humans in the
> **Prerequisites:** Git, Node.js 22+, Docker & Docker Compose

```bash
git clone https://github.com/centerbitco/placet.git
git clone https://github.com/placet-io/placet.git
cd placet
cp .env.example .env
make setup
Expand All @@ -83,7 +83,7 @@ That's it. `make setup` installs dependencies, builds packages, starts all Docke

```bash
curl -X POST http://localhost:3001/api/v1/messages \
-H "Authorization: Bearer hp_your-key-here" \
-H "x-api-key: hp_your-key-here" \
-H "Content-Type: application/json" \
-d '{"text": "Hello from my agent!", "status": "success"}'
```
Expand All @@ -94,7 +94,7 @@ curl -X POST http://localhost:3001/api/v1/messages \

```bash
curl -X POST http://localhost:3001/api/v1/messages \
-H "Authorization: Bearer hp_your-key-here" \
-H "x-api-key: hp_your-key-here" \
-H "Content-Type: application/json" \
-d '{
"text": "Deploy v2.1 to production?",
Expand Down
13 changes: 13 additions & 0 deletions apps/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,20 @@ async function bootstrap() {
.setTitle('Placet API')
.setDescription('Chat-based agent inbox for AI-human interaction')
.setVersion('0.1.0')
.addServer(
`http://localhost:${process.env.BACKEND_PORT ?? 3001}`,
'Local development',
)
.addBearerAuth()
.addApiKey(
{
type: 'apiKey',
name: 'x-api-key',
in: 'header',
description: 'Placet API key (hp_...)',
},
'api-key',
)
.addCookieAuth('access_token')
.build();

Expand Down
6 changes: 3 additions & 3 deletions apps/backend/src/modules/agents/agent-status.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiSecurity,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
Expand All @@ -13,8 +13,8 @@ import { AgentsService } from './agents.service';
import { EventsGateway } from '../events/events.gateway';
import { PingStatusDto } from './dto/ping-status.dto';

@ApiTags('Agent API')
@ApiBearerAuth()
@ApiTags('Status')
@ApiSecurity('api-key')
@UseGuards(ApiKeyGuard)
@Controller('api/v1/status')
export class AgentStatusController {
Expand Down
46 changes: 46 additions & 0 deletions apps/backend/src/modules/agents/agents-agent.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import {
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiSecurity,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { AgentResponse, ErrorResponse } from '../../common/swagger-responses';
import { ApiKeyGuard } from '../auth/guards/api-key.guard';
import type { RequestWithUser } from '../../common/types';
import { AgentsService } from './agents.service';
import { CreateAgentDto } from './dto/create-agent.dto';

@ApiTags('Agents')
@ApiSecurity('api-key')
@UseGuards(ApiKeyGuard)
@Controller('api/v1/agents')
export class AgentsAgentController {
constructor(private readonly agentsService: AgentsService) {}

@Get()
@ApiOperation({
summary: 'List channels (agents) accessible by this API key',
})
@ApiOkResponse({ description: 'List of agents', type: [AgentResponse] })
@ApiUnauthorizedResponse({
description: 'Invalid API key',
type: ErrorResponse,
})
async findAll(@Req() req: RequestWithUser) {
return this.agentsService.findAllByOwnerSimple(req.user.id);
}

@Post()
@ApiOperation({ summary: 'Create a new channel (agent)' })
@ApiCreatedResponse({ description: 'Agent created', type: AgentResponse })
@ApiUnauthorizedResponse({
description: 'Invalid API key',
type: ErrorResponse,
})
async create(@Req() req: RequestWithUser, @Body() dto: CreateAgentDto) {
return this.agentsService.create(req.user.id, dto);
}
}
2 changes: 1 addition & 1 deletion apps/backend/src/modules/agents/agents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { AgentsService } from './agents.service';
import { CreateAgentDto } from './dto/create-agent.dto';
import { UpdateAgentDto } from './dto/update-agent.dto';

@ApiTags('Agents')
@ApiTags('Agents', 'Frontend')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('api/agents')
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/modules/agents/agents.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { AgentsController } from './agents.controller';
import { AgentsAgentController } from './agents-agent.controller';
import { AgentStatusController } from './agent-status.controller';
import { AgentsService } from './agents.service';
import { EventsModule } from '../events/events.module';

@Module({
imports: [EventsModule],
controllers: [AgentsController, AgentStatusController],
controllers: [AgentsController, AgentsAgentController, AgentStatusController],
providers: [AgentsService],
exports: [AgentsService],
})
Expand Down
8 changes: 8 additions & 0 deletions apps/backend/src/modules/agents/agents.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ export class AgentsService {
private readonly s3: S3Service,
) {}

async findAllByOwnerSimple(ownerId: string) {
return this.prisma.agent.findMany({
where: { ownerId },
select: AGENT_SELECT,
orderBy: { createdAt: 'desc' },
});
}

async findAllByOwner(ownerId: string) {
const agents = await this.prisma.agent.findMany({
where: { ownerId },
Expand Down
5 changes: 2 additions & 3 deletions apps/backend/src/modules/auth/guards/api-key.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,12 @@ export class ApiKeyGuard implements CanActivate {

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<FastifyRequest>();
const authHeader = request.headers['authorization'] ?? '';
const rawKey = (request.headers['x-api-key'] as string) ?? '';

if (!authHeader.startsWith('Bearer hp_')) {
if (!rawKey.startsWith('hp_')) {
throw new UnauthorizedException('Missing or invalid API key');
}

const rawKey = authHeader.slice(7); // Remove "Bearer "
const keyHash = createHash('sha256').update(rawKey).digest('hex');

const apiKey = await this.prisma.apiKey.findUnique({
Expand Down
38 changes: 38 additions & 0 deletions apps/backend/src/modules/events/events.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { JwtService } from '@nestjs/jwt';
import { createHash } from 'crypto';
import { Server, Socket } from 'socket.io';
import { PrismaService } from '../../prisma/prisma.service';

Expand Down Expand Up @@ -40,6 +41,13 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
handleConnection(client: Socket) {
// Auth exclusively via handshake auth object — never from URL query params
const token = client.handshake.auth?.token as string | undefined;
const apiKey = client.handshake.auth?.apiKey as string | undefined;

if (apiKey) {
// Agent API key auth — resolve owner from hashed key
void this.authenticateWithApiKey(client, apiKey);
return;
}

if (!token) {
this.logger.warn(`WS connection rejected: no token (${client.id})`);
Expand All @@ -61,6 +69,36 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
}
}

private async authenticateWithApiKey(client: Socket, rawKey: string) {
if (!rawKey.startsWith('hp_')) {
this.logger.warn(
`WS connection rejected: invalid API key format (${client.id})`,
);
client.disconnect(true);
return;
}

const keyHash = createHash('sha256').update(rawKey).digest('hex');
const apiKey = await this.prisma.apiKey.findUnique({
where: { keyHash },
select: { userId: true },
});

if (!apiKey) {
this.logger.warn(
`WS connection rejected: unknown API key (${client.id})`,
);
client.disconnect(true);
return;
}

(client.data as Record<string, unknown>).userId = apiKey.userId;
void client.join(`user:${apiKey.userId}`);
this.logger.log(
`Client connected via API key: ${client.id} (user: ${apiKey.userId})`,
);
}

handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
}
Expand Down
6 changes: 3 additions & 3 deletions apps/backend/src/modules/files/files-agent.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiConsumes,
ApiCreatedResponse,
ApiForbiddenResponse,
Expand All @@ -20,6 +19,7 @@ import {
ApiOperation,
ApiProduces,
ApiQuery,
ApiSecurity,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
Expand All @@ -33,8 +33,8 @@ import type { RequestWithUser } from '../../common/types';
import { FilesService } from './files.service';
import { PrismaService } from '../../prisma/prisma.service';

@ApiTags('Agent API')
@ApiBearerAuth()
@ApiTags('Files')
@ApiSecurity('api-key')
@UseGuards(ApiKeyGuard)
@Controller('api/v1/files')
export class FilesAgentController {
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/modules/files/files.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import type { RequestWithUser } from '../../common/types';
import { PrismaService } from '../../prisma/prisma.service';
import { FilesService } from './files.service';

@ApiTags('Files')
@ApiTags('Files', 'Frontend')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('api/files')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import {
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiForbiddenResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiQuery,
ApiSecurity,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
Expand All @@ -32,8 +32,8 @@ import type { RequestWithUser } from '../../common/types';
import { MessagesService } from './messages.service';
import { CreateMessageDto } from './dto/create-message.dto';

@ApiTags('Agent API')
@ApiBearerAuth()
@ApiTags('Messages')
@ApiSecurity('api-key')
@UseGuards(ApiKeyGuard)
@Controller('api/v1/messages')
export class MessagesAgentController {
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/modules/messages/messages.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { MessagesService } from './messages.service';
import { CreateUserMessageDto } from './dto/create-user-message.dto';
import { RespondReviewDto } from './dto/respond-review.dto';

@ApiTags('Messages')
@ApiTags('Messages', 'Frontend')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('api/messages')
Expand Down
Loading
Loading