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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,7 @@ dist
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

# Backend-specific config (generated by sb mcp sync)
.codex/
.gemini/
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ personal-context-protocol/
├── packages/
│ ├── api/ # PCP server (MCP tools, services, data layer)
│ └── cli/ # SB CLI (sb command)
├── stories/ # Feature specs and design docs
│ └── cli/ # CLI-related stories
├── supabase/
│ └── migrations/ # Database migrations
├── AGENTS.md # Agent onboarding (points to CLAUDE.md)
Expand All @@ -38,6 +40,20 @@ personal-context-protocol/
└── README.md # This file
```

## Stories

Feature work lives in `stories/`, grouped by domain. Each story contains specs, research, and the feature-specific source files that don't belong in a generic shared folder:

```
stories/
├── cli/ # CLI features (backends, flags, install)
├── channels/ # Messaging integrations
├── mcp/ # MCP tools and server
└── agents/ # Multi-agent orchestration
```

Stories are living documents — update them as the feature evolves.

## Key Technologies

- **Runtime**: Node.js 18+, TypeScript, Yarn 4 workspaces
Expand Down
41 changes: 41 additions & 0 deletions packages/api/src/mcp/auth/pcp-auth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,47 @@ describe('PcpAuthProvider', () => {
});
});

// Regression: web portal was redirecting to /mcp/auth/callback without
// refresh_token, causing "Missing refresh token" error for MCP clients.
// The auth callback MUST receive both access_token and refresh_token
// from the web portal so the token exchange can store the Supabase
// refresh token for later use.
it('should require refresh_token for successful callback (regression)', async () => {
const pendingId = setupPendingAuth(provider);
mockSuccessfulAuth();

// Callback with access_token but NO refresh_token should still
// produce an auth code — the provider doesn't validate this, the
// HTTP layer does. But verify the stored refresh token propagates
// through to the code exchange.
const callbackResult = await provider.handleAuthCallback({
pendingId,
accessToken: 'supabase-jwt',
refreshToken: 'supabase-rt-required',
});

expect('code' in callbackResult).toBe(true);
if (!('code' in callbackResult)) return;

// Exchange the code and verify refresh token was stored
currentMcpTokensChain = mockChain();
mockInsert.mockReturnValue({ error: null });

const tokenResult = await provider.exchangeAuthorizationCode({
code: callbackResult.code,
codeVerifier: 'test-verifier',
clientId: 'test-client',
});

expect('access_token' in tokenResult).toBe(true);
if (!('access_token' in tokenResult)) return;

// The insert call should contain the supabase refresh token
expect(mockInsert).toHaveBeenCalled();
const insertArgs = mockInsert.mock.calls[0]?.[0];
expect(insertArgs).toHaveProperty('supabase_refresh_token', 'supabase-rt-required');
});

it('should consume the pending auth after successful callback', async () => {
const pendingId = setupPendingAuth(provider);
mockSuccessfulAuth();
Expand Down
13 changes: 10 additions & 3 deletions packages/api/src/mcp/auth/pcp-auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export interface AuthCallbackResult {
// Constants
// ============================================================================

// TODO: Consider extending Supabase JWT expiry to 30 days (2592000s) in dashboard
// and updating this constant to match. Current 1-hour expiry works via refresh
// tokens, but a longer JWT reduces refresh frequency for MCP clients.
const ACCESS_TOKEN_LIFETIME = 3600; // 1 hour (Supabase JWT default)
const REFRESH_TOKEN_LIFETIME_DAYS = 90;
const REFRESH_TOKEN_LIFETIME_MS = REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -175,7 +178,7 @@ export class PcpAuthProvider {
async exchangeAuthorizationCode(params: {
code: string;
codeVerifier: string;
clientId: string;
clientId?: string;
}): Promise<OAuthTokenResponse | OAuthErrorResponse> {
const codeData = this.authCodes.get(params.code);
if (!codeData) {
Expand All @@ -187,6 +190,10 @@ export class PcpAuthProvider {
return { error: 'invalid_grant', error_description: 'Authorization code expired' };
}

// Fall back to the client_id stored in the auth code (from /authorize).
// Some clients (e.g. Codex) don't send client_id in the token exchange body.
const clientId = params.clientId || codeData.clientId;

// Verify PKCE
if (codeData.codeChallenge && params.codeVerifier) {
const computedChallenge = crypto
Expand All @@ -212,7 +219,7 @@ export class PcpAuthProvider {
.from('mcp_tokens')
.insert({
user_id: codeData.userId,
client_id: params.clientId,
client_id: clientId,
refresh_token: refreshToken,
supabase_refresh_token: codeData.supabaseRefreshToken,
scopes: ['mcp:tools'],
Expand All @@ -230,7 +237,7 @@ export class PcpAuthProvider {
logger.info('MCP tokens issued', {
userId: codeData.userId,
email: codeData.userEmail,
clientId: params.clientId,
clientId,
refreshTokenExpires: expiresAt.toISOString(),
});

Expand Down
28 changes: 26 additions & 2 deletions packages/api/src/mcp/tools/memory-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,18 +142,41 @@ export async function handleRemember(args: unknown, dataComposer: DataComposer)
const params = rememberSchema.parse(args);
const { user, resolvedBy } = await resolveUserOrThrow(params, dataComposer);

// If there's an active session, attach its ID to the memory metadata for traceability.
// Never require a session — memories are too important to lose.
let sessionId: string | undefined;
try {
const activeSession = await dataComposer.repositories.memory.getActiveSession(
user.id,
params.agentId,
);
sessionId = activeSession?.id;
} catch {
// Session lookup failed — save the memory anyway
}

const metadata = {
...params.metadata,
...(sessionId ? { sessionId } : {}),
};

const memory = await dataComposer.repositories.memory.remember({
userId: user.id,
content: params.content,
source: params.source as MemorySource,
salience: params.salience as Salience,
topics: params.topics,
metadata: params.metadata,
metadata,
expiresAt: params.expiresAt ? new Date(params.expiresAt) : undefined,
agentId: params.agentId,
});

logger.info(`Memory created for user ${user.id}`, { memoryId: memory.id, source: memory.source, agentId: params.agentId });
logger.info(`Memory created for user ${user.id}`, {
memoryId: memory.id,
source: memory.source,
agentId: params.agentId,
sessionId: sessionId || 'none',
});

return {
content: [
Expand All @@ -170,6 +193,7 @@ export async function handleRemember(args: unknown, dataComposer: DataComposer)
salience: memory.salience,
topics: memory.topics,
agentId: memory.agentId,
sessionId: sessionId || null,
createdAt: memory.createdAt.toISOString(),
},
},
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/backends/claude.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Claude Code Backend Adapter
*
* Identity injection via --append-system-prompt <tmpfile>
* MCP config via --mcp-config <path>
*/

import { existsSync } from 'fs';
import { join } from 'path';
import { createIdentityPromptFile } from './identity.js';
import type { BackendAdapter, BackendConfig, PreparedBackend } from './types.js';

export class ClaudeAdapter implements BackendAdapter {
readonly name = 'claude';
readonly binary = 'claude';

prepare(config: BackendConfig): PreparedBackend {
const { promptFile, cleanup } = createIdentityPromptFile(config.agentId);

const args: string[] = [];

// Prompt mode vs interactive
if (config.prompt) {
args.push('-p');
}

// Model + identity
args.push('--model', config.model);
args.push('--append-system-prompt', promptFile);

// MCP config (if present in CWD)
const mcpConfig = join(process.cwd(), '.mcp.json');
if (existsSync(mcpConfig)) {
args.push('--mcp-config', mcpConfig);
}

// Passthrough flags
args.push(...config.passthroughArgs);

// Prompt as a single string after -p
if (config.prompt) {
args.push(config.prompt);
}

return {
binary: this.binary,
args,
env: { AGENT_ID: config.agentId },
cleanup,
};
}
}
44 changes: 44 additions & 0 deletions packages/cli/src/backends/codex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Codex CLI Backend Adapter
*
* Identity injection via --config model_instructions_file=<tmpfile>
* MCP config via --config mcp_servers (TOML format, not yet implemented)
*
* Docs: https://developers.openai.com/codex/cli/
*/

import { createIdentityPromptFile } from './identity.js';
import type { BackendAdapter, BackendConfig, PreparedBackend } from './types.js';

export class CodexAdapter implements BackendAdapter {
readonly name = 'codex';
readonly binary = 'codex';

prepare(config: BackendConfig): PreparedBackend {
const { promptFile, cleanup } = createIdentityPromptFile(config.agentId);

const args: string[] = [];

// Identity injection via config override
args.push('--config', `model_instructions_file=${promptFile}`);

// Model
args.push('--model', config.model);

// Passthrough flags
args.push(...config.passthroughArgs);

// Positional args spread individually so subcommands work
// e.g. "sb -b codex mcp login supabase" → codex ... mcp login supabase
if (config.promptParts.length > 0) {
args.push(...config.promptParts);
}

return {
binary: this.binary,
args,
env: { AGENT_ID: config.agentId },
cleanup,
};
}
}
50 changes: 50 additions & 0 deletions packages/cli/src/backends/gemini.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Gemini CLI Backend Adapter
*
* Identity injection via GEMINI_SYSTEM_MD=<tmpfile> env var
* MCP config via .gemini/settings.json (not yet implemented)
*
* Docs: https://geminicli.com/docs/
*/

import { createIdentityPromptFile } from './identity.js';
import type { BackendAdapter, BackendConfig, PreparedBackend } from './types.js';

export class GeminiAdapter implements BackendAdapter {
readonly name = 'gemini';
readonly binary = 'gemini';

prepare(config: BackendConfig): PreparedBackend {
const { promptFile, cleanup } = createIdentityPromptFile(config.agentId);

const args: string[] = [];

// Model
args.push('-m', config.model);

// Prompt mode: gemini uses -p for one-shot
// Interactive is the default (no flag needed)
if (config.prompt) {
args.push('-p');
}

// Passthrough flags
args.push(...config.passthroughArgs);

// Prompt as a single string after -p
if (config.prompt) {
args.push(config.prompt);
}

return {
binary: this.binary,
args,
// Identity injection via env var — points to our temp file
env: {
AGENT_ID: config.agentId,
GEMINI_SYSTEM_MD: promptFile,
},
cleanup,
};
}
}
Loading