diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ac7945c..72d512b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -635,10 +635,10 @@ The API is built on Next.js 16 App Router with standardized response formats. | **Tools** | `GET /api/tools` | List/search tools | | | `POST /api/tools/execute/[...slug]` | Execute tool (SSE) | | **Agents** | `GET /api/agents` | List user agents | -| | `POST /api/agents/[id]/conversation/[convId]` | Chat with agent (SSE) | +| | `POST /api/{username}/agents/{uid}/conversation/{convId}` | Chat with agent (SSE) | | **Collections** | `GET /api/collections` | List user collections | | | `POST /api/collections/[id]/tools` | Add tool to collection | -| **MCP** | `POST /api/mcp/{user}/{slug}/{transport}` | MCP protocol | +| **MCP** | `POST /api/mcp/{username}/{slug}/{transport}` | MCP protocol | | **Sync** | `POST /api/sync/changes` | Cron: npm changes | | **Stats** | `GET /api/stats` | Registry statistics | diff --git a/apps/web/scripts/test-mcp.sh b/apps/web/scripts/test-mcp.sh index 8ea43c0..fdff55c 100755 --- a/apps/web/scripts/test-mcp.sh +++ b/apps/web/scripts/test-mcp.sh @@ -1,49 +1,67 @@ #!/bin/bash # Test script for MCP endpoints -# Usage: ./test-mcp.sh [base-url] +# Usage: ./test-mcp.sh [base-url] [api-key] +# +# The new MCP URL format is: /api/mcp/{username}/{slug}/{transport} +# Collection ID is also supported as slug for backwards compatibility -COLLECTION_ID=${1:-""} -BASE_URL=${2:-"https://tpmjs.com"} +USERNAME=${1:-""} +COLLECTION_SLUG=${2:-""} +BASE_URL=${3:-"https://tpmjs.com"} +API_KEY=${4:-""} -if [ -z "$COLLECTION_ID" ]; then - echo "Usage: ./test-mcp.sh [base-url]" - echo "Example: ./test-mcp.sh clx123abc https://tpmjs.com" +if [ -z "$USERNAME" ] || [ -z "$COLLECTION_SLUG" ]; then + echo "Usage: ./test-mcp.sh [base-url] [api-key]" + echo "Example: ./test-mcp.sh ajax my-collection https://tpmjs.com tpmjs_sk_xxx" + echo "" + echo "Note: Collection ID can also be used as slug for backwards compatibility" exit 1 fi +# Build auth header if API key provided +AUTH_HEADER="" +if [ -n "$API_KEY" ]; then + AUTH_HEADER="-H \"Authorization: Bearer $API_KEY\"" +fi + echo "================================================" -echo "Testing MCP endpoints for collection: $COLLECTION_ID" +echo "Testing MCP endpoints" +echo "Username: $USERNAME" +echo "Collection: $COLLECTION_SLUG" echo "Base URL: $BASE_URL" +echo "Auth: ${API_KEY:+API key provided}" echo "================================================" echo "" # Test 1: HTTP transport - GET (server info) echo "1. Testing HTTP GET (server info)..." -echo " GET $BASE_URL/api/collections/$COLLECTION_ID/mcp/http" -curl -s "$BASE_URL/api/collections/$COLLECTION_ID/mcp/http" | jq . +echo " GET $BASE_URL/api/mcp/$USERNAME/$COLLECTION_SLUG/http" +eval curl -s "$BASE_URL/api/mcp/$USERNAME/$COLLECTION_SLUG/http" $AUTH_HEADER | jq . echo "" # Test 2: HTTP transport - initialize echo "2. Testing HTTP POST (initialize)..." -echo " POST $BASE_URL/api/collections/$COLLECTION_ID/mcp/http" -curl -s -X POST "$BASE_URL/api/collections/$COLLECTION_ID/mcp/http" \ +echo " POST $BASE_URL/api/mcp/$USERNAME/$COLLECTION_SLUG/http" +eval curl -s -X POST "$BASE_URL/api/mcp/$USERNAME/$COLLECTION_SLUG/http" \ -H "Content-Type: application/json" \ + $AUTH_HEADER \ -d '{"jsonrpc":"2.0","method":"initialize","id":1}' | jq . echo "" # Test 3: HTTP transport - tools/list echo "3. Testing HTTP POST (tools/list)..." -echo " POST $BASE_URL/api/collections/$COLLECTION_ID/mcp/http" -curl -s -X POST "$BASE_URL/api/collections/$COLLECTION_ID/mcp/http" \ +echo " POST $BASE_URL/api/mcp/$USERNAME/$COLLECTION_SLUG/http" +eval curl -s -X POST "$BASE_URL/api/mcp/$USERNAME/$COLLECTION_SLUG/http" \ -H "Content-Type: application/json" \ + $AUTH_HEADER \ -d '{"jsonrpc":"2.0","method":"tools/list","id":2}' | jq . echo "" # Test 4: SSE transport - GET (event stream) echo "4. Testing SSE GET (event stream)..." -echo " GET $BASE_URL/api/collections/$COLLECTION_ID/mcp/sse" -curl -s -N "$BASE_URL/api/collections/$COLLECTION_ID/mcp/sse" & +echo " GET $BASE_URL/api/mcp/$USERNAME/$COLLECTION_SLUG/sse" +eval curl -s -N "$BASE_URL/api/mcp/$USERNAME/$COLLECTION_SLUG/sse" $AUTH_HEADER & SSE_PID=$! sleep 2 kill $SSE_PID 2>/dev/null @@ -52,26 +70,28 @@ echo "" # Test 5: SSE transport - initialize echo "5. Testing SSE POST (initialize)..." -echo " POST $BASE_URL/api/collections/$COLLECTION_ID/mcp/sse" -curl -s -X POST "$BASE_URL/api/collections/$COLLECTION_ID/mcp/sse" \ +echo " POST $BASE_URL/api/mcp/$USERNAME/$COLLECTION_SLUG/sse" +eval curl -s -X POST "$BASE_URL/api/mcp/$USERNAME/$COLLECTION_SLUG/sse" \ -H "Content-Type: application/json" \ + $AUTH_HEADER \ -d '{"jsonrpc":"2.0","method":"initialize","id":1}' echo "" echo "" # Test 6: SSE transport - tools/list echo "6. Testing SSE POST (tools/list)..." -echo " POST $BASE_URL/api/collections/$COLLECTION_ID/mcp/sse" -curl -s -X POST "$BASE_URL/api/collections/$COLLECTION_ID/mcp/sse" \ +echo " POST $BASE_URL/api/mcp/$USERNAME/$COLLECTION_SLUG/sse" +eval curl -s -X POST "$BASE_URL/api/mcp/$USERNAME/$COLLECTION_SLUG/sse" \ -H "Content-Type: application/json" \ + $AUTH_HEADER \ -d '{"jsonrpc":"2.0","method":"tools/list","id":2}' echo "" echo "" # Test 7: Invalid transport echo "7. Testing invalid transport..." -echo " GET $BASE_URL/api/collections/$COLLECTION_ID/mcp/invalid" -curl -s "$BASE_URL/api/collections/$COLLECTION_ID/mcp/invalid" | jq . +echo " GET $BASE_URL/api/mcp/$USERNAME/$COLLECTION_SLUG/invalid" +eval curl -s "$BASE_URL/api/mcp/$USERNAME/$COLLECTION_SLUG/invalid" $AUTH_HEADER | jq . echo "" echo "================================================" diff --git a/apps/web/src/app/agents/[id]/chat/[chatId]/page.tsx b/apps/web/src/app/agents/[id]/chat/[chatId]/page.tsx index 4788e0f..ca5c669 100644 --- a/apps/web/src/app/agents/[id]/chat/[chatId]/page.tsx +++ b/apps/web/src/app/agents/[id]/chat/[chatId]/page.tsx @@ -55,6 +55,7 @@ interface Agent { collectionCount: number; createdBy: { id: string; + username: string | null; name: string; image: string | null; }; @@ -266,11 +267,11 @@ export default function PublicAgentChatPage(): React.ReactElement { // Fetch messages for conversation (initial load - gets most recent 50) const fetchMessages = useCallback(async () => { - if (!agent) return; + if (!agent || !agent.createdBy.username) return; try { const response = await fetch( - `/api/agents/${agent.uid}/conversation/${conversationId}?limit=50` + `/api/${agent.createdBy.username}/agents/${agent.uid}/conversation/${conversationId}?limit=50` ); if (response.status === 404) { // Conversation doesn't exist yet, that's fine @@ -294,7 +295,14 @@ export default function PublicAgentChatPage(): React.ReactElement { // Load older messages when scrolling up // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Virtuoso scroll handler with pagination logic const loadMoreMessages = useCallback(async () => { - if (!agent || isLoadingMore || !hasMoreMessages || messages.length === 0) return; + if ( + !agent || + !agent.createdBy.username || + isLoadingMore || + !hasMoreMessages || + messages.length === 0 + ) + return; setIsLoadingMore(true); try { @@ -305,7 +313,7 @@ export default function PublicAgentChatPage(): React.ReactElement { if (!beforeTimestamp) return; const response = await fetch( - `/api/agents/${agent.uid}/conversation/${conversationId}?limit=50&before=${encodeURIComponent(beforeTimestamp)}` + `/api/${agent.createdBy.username}/agents/${agent.uid}/conversation/${conversationId}?limit=50&before=${encodeURIComponent(beforeTimestamp)}` ); if (!response.ok) return; @@ -344,7 +352,7 @@ export default function PublicAgentChatPage(): React.ReactElement { // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Chat send handler with streaming and tool calls const handleSend = async () => { - if (!input.trim() || !agent || isSending) return; + if (!input.trim() || !agent || !agent.createdBy.username || isSending) return; const messageContent = input.trim(); setInput(''); @@ -363,11 +371,14 @@ export default function PublicAgentChatPage(): React.ReactElement { setMessages((prev) => [...prev, userMessage]); try { - const response = await fetch(`/api/agents/${agent.uid}/conversation/${conversationId}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message: messageContent }), - }); + const response = await fetch( + `/api/${agent.createdBy.username}/agents/${agent.uid}/conversation/${conversationId}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: messageContent }), + } + ); if (!response.ok) { const errorData = await response.json(); diff --git a/apps/web/src/app/api/cron/discord-summary/route.ts b/apps/web/src/app/api/cron/discord-summary/route.ts index 9a3d1c4..bf0103d 100644 --- a/apps/web/src/app/api/cron/discord-summary/route.ts +++ b/apps/web/src/app/api/cron/discord-summary/route.ts @@ -47,10 +47,15 @@ export async function POST(request: NextRequest) { } try { - // 3. Verify agent exists + // 3. Verify agent exists and get owner info const agent = await prisma.agent.findUnique({ where: { id: agentId }, - select: { id: true, name: true }, + select: { + id: true, + uid: true, + name: true, + user: { select: { username: true } }, + }, }); if (!agent) { @@ -60,6 +65,13 @@ export async function POST(request: NextRequest) { ); } + if (!agent.user.username || !agent.uid) { + return NextResponse.json( + { success: false, error: 'Agent owner has no username or agent has no UID' }, + { status: 500 } + ); + } + // 4. Get or create a conversation for this cron job // Use a fixed slug for the cron job so we reuse the same conversation const cronSlug = 'discord-summary-cron'; @@ -100,10 +112,10 @@ Then use the discordPostTool to post the summary to channel "${summaryChannelId} If there are no messages in the past 24 hours, post a brief message saying the server was quiet.`; - // 6. Call the agent conversation endpoint + // 6. Call the agent conversation endpoint (new URL format) const baseUrl = env.BETTER_AUTH_URL || `http://localhost:${process.env.PORT || 3000}`; const response = await fetch( - `${baseUrl}/api/agents/${agentId}/conversation/${conversation.id}`, + `${baseUrl}/api/${agent.user.username}/agents/${agent.uid}/conversation/${conversation.id}`, { method: 'POST', headers: { diff --git a/apps/web/src/app/collections/[id]/page.tsx b/apps/web/src/app/collections/[id]/page.tsx index f448a32..034e33a 100644 --- a/apps/web/src/app/collections/[id]/page.tsx +++ b/apps/web/src/app/collections/[id]/page.tsx @@ -46,13 +46,13 @@ interface PublicCollection { tools: CollectionTool[]; } -function McpUrlSection({ collectionId }: { collectionId: string }) { +function McpUrlSection({ username, slug }: { username: string; slug: string }) { const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null); const [showConfig, setShowConfig] = useState(false); const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com'; - const httpUrl = `${baseUrl}/api/collections/${collectionId}/mcp/http`; - const sseUrl = `${baseUrl}/api/collections/${collectionId}/mcp/sse`; + const httpUrl = `${baseUrl}/api/mcp/${username}/${slug}/http`; + const sseUrl = `${baseUrl}/api/mcp/${username}/${slug}/sse`; const copyToClipboard = async (url: string, type: 'http' | 'sse') => { await navigator.clipboard.writeText(url); @@ -66,7 +66,9 @@ function McpUrlSection({ collectionId }: { collectionId: string }) { "command": "npx", "args": [ "mcp-remote", - "${httpUrl}" + "${httpUrl}", + "--header", + "Authorization: Bearer YOUR_TPMJS_API_KEY" ] } } @@ -309,7 +311,9 @@ export default function PublicCollectionDetailPage(): React.ReactElement { {/* MCP URLs */} - + {collection.createdBy?.username && collection.slug && ( + + )} {/* Tools */}
diff --git a/apps/web/src/app/docs/agents/page.tsx b/apps/web/src/app/docs/agents/page.tsx index 019bf22..592c1d6 100644 --- a/apps/web/src/app/docs/agents/page.tsx +++ b/apps/web/src/app/docs/agents/page.tsx @@ -692,18 +692,30 @@ Always cite your sources and be transparent about limitations.`}

- uid: Your agent's unique - identifier + username: The agent owner's + username +

+

+ agent-uid: The agent's unique + identifier (slug)

conversationId: Unique ID for the conversation (create your own or use a new ID to start a new conversation)

+
+

+ Authentication Required: Include + the header{' '} + Authorization: Bearer YOUR_TPMJS_API_KEY{' '} + in all requests. Get your API key from the dashboard. +

+
- /api/agents/[uid]/conversation/[conversationId] + /api/[username]/agents/[uid]/conversation/[conversationId]

- Send a message and stream the AI response via SSE. + Send a message and stream the AI response via SSE. Requires TPMJS API key.

@@ -838,11 +856,11 @@ while (true) { GET - /api/agents/[uid]/conversation/[conversationId] + /api/[username]/agents/[uid]/conversation/[conversationId]

- Get the full conversation history with all messages. + Get the full conversation history with all messages. Requires TPMJS API key.

@@ -851,11 +869,11 @@ while (true) { GET - /api/agents/[uid]/conversations + /api/[username]/agents/[uid]/conversations

- List all conversations for an agent. + List all conversations for an agent. Requires TPMJS API key.

diff --git a/apps/web/src/app/docs/page.tsx b/apps/web/src/app/docs/page.tsx index abe3b17..2780f5a 100644 --- a/apps/web/src/app/docs/page.tsx +++ b/apps/web/src/app/docs/page.tsx @@ -1219,10 +1219,11 @@ export TPMJS_EXECUTOR_URL=https://executor.mycompany.com`}

This URL is what you'll use to connect MCP clients to your collection. + You'll also need your TPMJS API key for authentication.

@@ -1244,7 +1245,9 @@ export TPMJS_EXECUTOR_URL=https://executor.mycompany.com`} "command": "npx", "args": [ "mcp-remote", - "https://tpmjs.com/api/collections//mcp/http" + "https://tpmjs.com/api/mcp///http", + "--header", + "Authorization: Bearer YOUR_TPMJS_API_KEY" ] } } @@ -1275,7 +1278,8 @@ export TPMJS_EXECUTOR_URL=https://executor.mycompany.com`} /mcp/http`} + -- npx mcp-remote https://tpmjs.com/api/mcp///http \\ + --header "Authorization: Bearer YOUR_TPMJS_API_KEY"`} />

This automatically adds the server to your Claude Code configuration. @@ -1305,7 +1309,7 @@ export TPMJS_EXECUTOR_URL=https://executor.mycompany.com`} @@ -1318,6 +1322,9 @@ export TPMJS_EXECUTOR_URL=https://executor.mycompany.com`} Accept: application/json, text/event-stream

+

+ Authorization: Bearer YOUR_TPMJS_API_KEY +

diff --git a/apps/web/src/app/docs/sharing/page.tsx b/apps/web/src/app/docs/sharing/page.tsx index 93eceba..c5f9125 100644 --- a/apps/web/src/app/docs/sharing/page.tsx +++ b/apps/web/src/app/docs/sharing/page.tsx @@ -370,7 +370,9 @@ Invalid usernames: "command": "npx", "args": [ "mcp-remote", - "https://tpmjs.com/api/collections/{collection-id}/mcp/http" + "https://tpmjs.com/api/mcp/{username}/{collection-slug}/http", + "--header", + "Authorization: Bearer YOUR_TPMJS_API_KEY" ] } } @@ -602,10 +604,10 @@ Invalid usernames: Agent Conversation - /api/chat/{'{username}'}/{'{uid}'}/conversation/{'{id}'} + /api/{'{username}'}/agents/{'{uid}'}/conversation/{'{id}'} - /api/chat/ajax/research-bot/conversation/abc123 + /api/ajax/agents/research-bot/conversation/abc123 diff --git a/apps/web/src/test/agents.test.ts b/apps/web/src/test/agents.test.ts index 0bebe70..5379dc8 100644 --- a/apps/web/src/test/agents.test.ts +++ b/apps/web/src/test/agents.test.ts @@ -87,6 +87,7 @@ describe.skipIf(!INTEGRATION_TESTS_ENABLED)('Agent Endpoints', () => { name: string; userId: string; provider: string; + user: { username: string | null }; } | null = null; let testConversationId: string | null = null; @@ -110,11 +111,15 @@ describe.skipIf(!INTEGRATION_TESTS_ENABLED)('Agent Endpoints', () => { name: true, userId: true, provider: true, + user: { select: { username: true } }, }, }); if (!testAgent) { console.warn(`⚠️ Test agent "${TEST_AGENT_UID}" not found. Skipping agent tests.`); + } else if (!testAgent.user.username) { + console.warn(`⚠️ Test agent owner has no username. Skipping agent tests.`); + testAgent = null; } } catch (error) { console.warn('⚠️ Could not connect to database. Skipping agent tests.', error); @@ -139,7 +144,7 @@ describe.skipIf(!INTEGRATION_TESTS_ENABLED)('Agent Endpoints', () => { } }); - describe('POST /api/agents/[id]/conversation/[conversationId]', () => { + describe('POST /api/[username]/agents/[uid]/conversation/[conversationId]', () => { it('should create a new conversation and receive a response', async () => { if (!serverAvailable || !testAgent) { console.log('Skipping: Server not available or no test agent'); @@ -147,7 +152,7 @@ describe.skipIf(!INTEGRATION_TESTS_ENABLED)('Agent Endpoints', () => { } const response = await fetch( - `${BASE_URL}/api/agents/${testAgent.uid}/conversation/${TEST_CONVERSATION_SLUG}`, + `${BASE_URL}/api/${testAgent.user.username}/agents/${testAgent.uid}/conversation/${TEST_CONVERSATION_SLUG}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -224,7 +229,7 @@ describe.skipIf(!INTEGRATION_TESTS_ENABLED)('Agent Endpoints', () => { } const response = await fetch( - `${BASE_URL}/api/agents/${testAgent.uid}/conversation/${TEST_CONVERSATION_SLUG}`, + `${BASE_URL}/api/${testAgent.user.username}/agents/${testAgent.uid}/conversation/${TEST_CONVERSATION_SLUG}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -260,7 +265,7 @@ describe.skipIf(!INTEGRATION_TESTS_ENABLED)('Agent Endpoints', () => { const toolConvSlug = `tool-test-${Date.now()}`; const response = await fetch( - `${BASE_URL}/api/agents/${testAgent.uid}/conversation/${toolConvSlug}`, + `${BASE_URL}/api/${testAgent.user.username}/agents/${testAgent.uid}/conversation/${toolConvSlug}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -312,7 +317,7 @@ describe.skipIf(!INTEGRATION_TESTS_ENABLED)('Agent Endpoints', () => { }); }); - describe('GET /api/agents/[id]/conversation/[conversationId]', () => { + describe('GET /api/[username]/agents/[uid]/conversation/[conversationId]', () => { it('should retrieve conversation history', async () => { if (!serverAvailable || !testAgent || !testConversationId) { console.log('Skipping: Server not available or no test conversation'); @@ -320,7 +325,7 @@ describe.skipIf(!INTEGRATION_TESTS_ENABLED)('Agent Endpoints', () => { } const response = await fetch( - `${BASE_URL}/api/agents/${testAgent.uid}/conversation/${TEST_CONVERSATION_SLUG}`, + `${BASE_URL}/api/${testAgent.user.username}/agents/${testAgent.uid}/conversation/${TEST_CONVERSATION_SLUG}`, { method: 'GET' } ); @@ -341,7 +346,7 @@ describe.skipIf(!INTEGRATION_TESTS_ENABLED)('Agent Endpoints', () => { } const response = await fetch( - `${BASE_URL}/api/agents/${testAgent.uid}/conversation/non-existent-conv-12345`, + `${BASE_URL}/api/${testAgent.user.username}/agents/${testAgent.uid}/conversation/non-existent-conv-12345`, { method: 'GET' } ); @@ -349,16 +354,19 @@ describe.skipIf(!INTEGRATION_TESTS_ENABLED)('Agent Endpoints', () => { }); }); - describe('GET /api/agents/[id]/conversations', () => { + describe('GET /api/[username]/agents/[uid]/conversations', () => { it('should list all conversations for an agent', async () => { if (!serverAvailable || !testAgent) { console.log('Skipping: Server not available or no test agent'); return; } - const response = await fetch(`${BASE_URL}/api/agents/${testAgent.id}/conversations`, { - method: 'GET', - }); + const response = await fetch( + `${BASE_URL}/api/${testAgent.user.username}/agents/${testAgent.uid}/conversations`, + { + method: 'GET', + } + ); expect(response.ok).toBe(true); @@ -380,7 +388,7 @@ describe.skipIf(!INTEGRATION_TESTS_ENABLED)('Agent Endpoints', () => { } const response = await fetch( - `${BASE_URL}/api/agents/${testAgent.id}/conversations?limit=1&offset=0`, + `${BASE_URL}/api/${testAgent.user.username}/agents/${testAgent.uid}/conversations?limit=1&offset=0`, { method: 'GET' } ); @@ -433,31 +441,19 @@ describe.skipIf(!INTEGRATION_TESTS_ENABLED)('Agent Endpoints', () => { }); }); - describe('Agent with lookup by ID or UID', () => { - it('should accept agent ID in URL', async () => { + describe('Agent with username/uid lookup', () => { + it('should accept agent with username/uid format', async () => { if (!serverAvailable || !testAgent) { console.log('Skipping: Server not available or no test agent'); return; } - const response = await fetch(`${BASE_URL}/api/agents/${testAgent.id}/conversations`, { - method: 'GET', - }); - - expect(response.ok).toBe(true); - const result = await response.json(); - expect(result.success).toBe(true); - }); - - it('should accept agent UID in URL', async () => { - if (!serverAvailable || !testAgent) { - console.log('Skipping: Server not available or no test agent'); - return; - } - - const response = await fetch(`${BASE_URL}/api/agents/${testAgent.uid}/conversations`, { - method: 'GET', - }); + const response = await fetch( + `${BASE_URL}/api/${testAgent.user.username}/agents/${testAgent.uid}/conversations`, + { + method: 'GET', + } + ); expect(response.ok).toBe(true); const result = await response.json();