Skip to content
Merged

Merg #13

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
68 changes: 68 additions & 0 deletions .github/workflows/build-mcp.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: MCP Build & Deploy
on:
push:
branches:
- main
- dev
jobs:
main:
runs-on: ubuntu-latest
environment:
name: ${{ github.ref == 'refs/heads/main' && 'mainnet' || 'dev' }}
steps:
- name: Install kubectl
uses: azure/setup-kubectl@v2.0
with:
version: "v1.23.6"
id: install

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1

- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1

- name: Set kubectl context
uses: azure/k8s-set-context@v3
with:
method: kubeconfig
kubeconfig: ${{ secrets.KUBECONFIG }}
context: microk8s

- name: Check k8s connection
run: kubectl get pods

- name: Store build time
id: build-time
shell: bash
run: >-
echo "::set-output name=time::$(date +%s)"

- name: Check out the repo
uses: actions/checkout@v2

- name: Set up QEMU
uses: docker/setup-qemu-action@v1

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1

- name: Build, tag, and push image to Amazon ECR
env:
DOCKER_TAG: indexnetwork/mcp:${{ steps.build-time.outputs.time }}
DOCKER_REGISTRY: 236785930124.dkr.ecr.us-east-1.amazonaws.com
run: |
docker build --build-arg VITE_PRIVY_APP_ID=${{ secrets.VITE_PRIVY_APP_ID }} -t $DOCKER_TAG .
docker tag $DOCKER_TAG $DOCKER_REGISTRY/$DOCKER_TAG
docker push $DOCKER_REGISTRY/$DOCKER_TAG
docker tag $DOCKER_TAG $DOCKER_REGISTRY/indexnetwork/mcp:latest-${GITHUB_REF#refs/heads/}
docker push $DOCKER_REGISTRY/indexnetwork/mcp:latest-${GITHUB_REF#refs/heads/}

- name: Deploy
run: |-
kubectl set image deployment/mcp mcp=236785930124.dkr.ecr.us-east-1.amazonaws.com/indexnetwork/mcp:${{ steps.build-time.outputs.time }} --namespace env-${{ github.ref == 'refs/heads/main' && 'mainnet' || github.ref_name }}
12 changes: 9 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ RUN bun install --frozen-lockfile --dev
# Copy source code
COPY . .

# Accept build arguments
ARG VITE_PRIVY_APP_ID

# Set environment variables from build arguments
ENV VITE_PRIVY_APP_ID=$VITE_PRIVY_APP_ID

# Build everything (client, widgets, server)
RUN bun run build

Expand All @@ -31,8 +37,8 @@ RUN bun install --frozen-lockfile
# Copy built files from builder stage
COPY --from=builder /app/dist ./dist

# Copy any other necessary files
COPY --from=builder /app/README.md ./README.md
# Copy public assets (favicon, etc.)
COPY --from=builder /app/public ./public

# Set environment variables
ENV NODE_ENV=production
Expand All @@ -46,4 +52,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD bun run -e "fetch('http://localhost:3002/mcp/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"

# Run the server
CMD ["bun", "run", "dist/server/index.js"]
CMD ["bun", "run", "start"]
2 changes: 2 additions & 0 deletions src/client/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import type { Connect } from 'vite';
import path from 'path';

// Middleware to disable Vite's host check for ngrok/tunnel support
const disableHostCheckMiddleware: Connect.NextHandleFunction = (_req, _res, next) => {
Expand All @@ -20,6 +21,7 @@ export default defineConfig({
},
],
root: 'src/client',
envDir: path.resolve(__dirname, '../..'), // <- tell Vite to use repo root .env
build: {
outDir: '../../dist/client',
emptyOutDir: true,
Expand Down
9 changes: 6 additions & 3 deletions src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,13 @@ export const config = {
},

// Discover connections polling configuration
// Uses accumulate + stability strategy: keeps polling until results stabilize or limits hit
discoverFilter: {
maxAttempts: Number(process.env.DISCOVER_FILTER_MAX_ATTEMPTS ?? '6'),
initialDelayMs: Number(process.env.DISCOVER_FILTER_INITIAL_DELAY_MS ?? '2000'),
maxTotalWaitMs: Number(process.env.DISCOVER_FILTER_MAX_TOTAL_WAIT_MS ?? '30000'),
maxAttempts: Number(process.env.DISCOVER_FILTER_MAX_ATTEMPTS ?? '8'),
baseDelayMs: Number(process.env.DISCOVER_FILTER_BASE_DELAY_MS ?? '300'),
delayStepMs: Number(process.env.DISCOVER_FILTER_DELAY_STEP_MS ?? '200'),
stableThreshold: Number(process.env.DISCOVER_FILTER_STABLE_THRESHOLD ?? '2'),
maxTotalWaitMs: Number(process.env.DISCOVER_FILTER_MAX_TOTAL_WAIT_MS ?? '5000'),
},

// Auth storage configuration
Expand Down
8 changes: 8 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,19 @@ if (isProduction) {
const clientPath = path.join(process.cwd(), 'dist/client');
app.use(express.static(clientPath));

// Serve OAuth UI for GET /authorize after validation passes (authorizeRouter calls next())
// This catches the request after authorizeRouter validates params and logs authorize_request
app.get('/authorize', (_req, res) => {
res.sendFile(path.join(clientPath, 'index.html'));
});

// Catch-all for client-side routing (after all API routes)
app.get('*', (req, res) => {
// Don't serve index.html for API routes
if (
req.path.startsWith('/mcp') ||
req.path.startsWith('/token') ||
req.path.startsWith('/.well-known') ||
req.path.startsWith('/api')
) {
return res.status(404).json({ error: 'Not found' });
Expand Down
176 changes: 122 additions & 54 deletions src/server/mcp/discoverConnections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
callVibecheck,
PrivyTokenExpiredError,
type DiscoverNewIntent,
type VibecheckResponse,
type DiscoverFilterResultItem,
} from '../protocol/client.js';
import { config } from '../config.js';

Expand Down Expand Up @@ -136,6 +136,115 @@ async function runVibechecksWithPool(
return results;
}

// =============================================================================
// Polling Helper: Accumulate + Stability Strategy
// =============================================================================

interface PollDiscoverFilterOptions {
privyToken: string;
intentIds: string[];
maxConnections: number;
}

/**
* Poll discover/filter with accumulate + stability strategy.
*
* Instead of stopping on first non-empty response, this:
* 1. Accumulates unique connections across multiple polls (by user.id)
* 2. Stops when: maxConnections reached OR results stabilize OR limits hit
*
* "Stable" means the connection count hasn't changed for `stableThreshold` consecutive polls.
*/
async function pollDiscoverFilterWithAccumulation(
opts: PollDiscoverFilterOptions
): Promise<DiscoverFilterResultItem[]> {
const { privyToken, intentIds, maxConnections } = opts;
const { maxAttempts, baseDelayMs, delayStepMs, stableThreshold, maxTotalWaitMs } = config.discoverFilter;

// Accumulate connections by user.id to dedupe across polls
const seenByUserId = new Map<string, DiscoverFilterResultItem>();
let lastCount = 0;
let stableAttempts = 0;
const startTime = Date.now();

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// Check total time limit
const elapsed = Date.now() - startTime;
if (elapsed >= maxTotalWaitMs) {
console.log(`[pollDiscoverFilter] Max total wait (${maxTotalWaitMs}ms) exceeded after ${attempt - 1} attempts`);
break;
}

// Linear backoff delay: baseDelayMs + delayStepMs * (attempt - 1)
const delayMs = Math.min(baseDelayMs + delayStepMs * (attempt - 1), maxTotalWaitMs - elapsed);
if (delayMs > 0) {
console.log(`[discoverConnectionsFromText] Attempt ${attempt}/${maxAttempts}: waiting ${delayMs}ms before calling discover/filter`);
await delay(delayMs);
}

try {
const filterResponse = await callDiscoverFilter(privyToken, {
intentIds,
excludeDiscovered: true,
page: 1,
limit: Math.max(maxConnections, 50), // Request at least 50 to catch more results
});

// Accumulate new connections
for (const result of filterResponse.results) {
const key = result.user.id;
if (!seenByUserId.has(key)) {
seenByUserId.set(key, result);
console.log(`[pollDiscoverFilter] Attempt ${attempt}: added new connection ${key} (total: ${seenByUserId.size})`);
}
}

// Early exit if we hit maxConnections
if (seenByUserId.size >= maxConnections) {
console.log(`[pollDiscoverFilter] Reached maxConnections (${maxConnections}) on attempt ${attempt}`);
break;
}

// Check stability
const currentCount = seenByUserId.size;

if (currentCount === 0) {
// No results yet - keep polling without stability check
console.log(`[discoverConnectionsFromText] Attempt ${attempt}: no results yet, will retry`);
} else {
// We have some results - check if stable
if (currentCount === lastCount) {
stableAttempts++;
console.log(`[pollDiscoverFilter] Attempt ${attempt}: count stable at ${currentCount} (stable for ${stableAttempts}/${stableThreshold})`);
} else {
// Results changed, reset stability counter
stableAttempts = 0;
}

// Stop if stable for enough consecutive polls
if (stableAttempts >= stableThreshold) {
console.log(`[pollDiscoverFilter] Results stable after ${attempt} attempts, stopping`);
break;
}

lastCount = currentCount;
}
} catch (error) {
// Re-throw auth errors - don't continue polling
if (error instanceof PrivyTokenExpiredError) {
throw error;
}
console.error(`[discoverConnectionsFromText] Attempt ${attempt} failed:`, error);
// Continue polling on transient errors
}
}

// Return accumulated connections, limited to maxConnections
const accumulated = Array.from(seenByUserId.values());
console.log(`[discoverConnectionsFromText] Found ${accumulated.length} connection(s) after polling`);
return accumulated.slice(0, maxConnections);
}

// =============================================================================
// Main Orchestrator Function
// =============================================================================
Expand All @@ -146,7 +255,7 @@ async function runVibechecksWithPool(
* Flow:
* 1. Exchange OAuth token for Privy token
* 2. Call discover/new to extract intents
* 3. Call discover/filter to find matching users
* 3. Poll discover/filter to find matching users (accumulate + stability)
* 4. Run vibechecks for each user with bounded concurrency
* 5. Return connections formatted for widget
*/
Expand Down Expand Up @@ -175,67 +284,26 @@ export async function discoverConnectionsFromText(
return { connections: [], intents: [] };
}

// Step C: Call discover/filter with bounded polling
// Step C: Poll discover/filter with accumulate + stability strategy
// The Protocol API has eventual consistency - intents are written synchronously
// but indexing happens in a background queue. We poll until we get results
// or hit our configured limits.
const limit = Math.min(opts.maxConnections, 100);
// but indexing happens in a background queue. We poll and accumulate results
// until they stabilize or we hit our configured limits.
const intentIds = intents.map(i => i.id);

const { maxAttempts, initialDelayMs, maxTotalWaitMs } = config.discoverFilter;
const startTime = Date.now();
let attempt = 0;
let filterResponse: Awaited<ReturnType<typeof callDiscoverFilter>> | null = null;

while (attempt < maxAttempts) {
const elapsed = Date.now() - startTime;
if (elapsed >= maxTotalWaitMs) {
console.log(`[discoverConnectionsFromText] Max total wait time (${maxTotalWaitMs}ms) exceeded after ${attempt} attempts`);
break;
}

// Wait before each attempt (including the first one, to give indexer time)
const delayMs = Math.min(initialDelayMs * (attempt + 1), maxTotalWaitMs - elapsed);
if (delayMs > 0) {
console.log(`[discoverConnectionsFromText] Attempt ${attempt + 1}/${maxAttempts}: waiting ${delayMs}ms before calling discover/filter`);
await delay(delayMs);
}

attempt++;

try {
filterResponse = await callDiscoverFilter(privyToken, {
intentIds,
excludeDiscovered: true,
page: 1,
limit,
});

// If we got results, we're done polling
if (filterResponse.results.length > 0) {
console.log(`[discoverConnectionsFromText] Found ${filterResponse.results.length} connection(s) on attempt ${attempt}`);
break;
}

console.log(`[discoverConnectionsFromText] Attempt ${attempt}: no results yet, will retry`);
} catch (error) {
// Re-throw auth errors - don't continue polling
if (error instanceof PrivyTokenExpiredError) {
throw error;
}
console.error(`[discoverConnectionsFromText] Attempt ${attempt} failed:`, error);
// Continue polling on transient errors
}
}
const filterResults = await pollDiscoverFilterWithAccumulation({
privyToken,
intentIds,
maxConnections: opts.maxConnections,
});

// If no results after polling, return empty
if (!filterResponse || filterResponse.results.length === 0) {
if (filterResults.length === 0) {
console.log('[discoverConnectionsFromText] No connections found after polling, returning with intents only');
return { connections: [], intents };
}

// Step D: Run vibechecks with bounded concurrency
const vibecheckTasks: VibecheckTask[] = filterResponse.results.map(result => ({
const vibecheckTasks: VibecheckTask[] = filterResults.map(result => ({
userId: result.user.id,
intentIds,
characterLimit: opts.characterLimit,
Expand All @@ -248,7 +316,7 @@ export async function discoverConnectionsFromText(
);

// Step E: Build ConnectionForWidget array
const connections: ConnectionForWidget[] = filterResponse.results.map(result => {
const connections: ConnectionForWidget[] = filterResults.map(result => {
const vibecheck = vibecheckResults.get(result.user.id);

return {
Expand Down
Loading
Loading