feat: allow public access to agents and collections with caller credentials
Users can now access other users' PUBLIC agents and collections by providing their own credentials in the request: For agents: - Provide `providerApiKey` for LLM access - Provide `env` object with tool environment variables - Owner's stored credentials are never shared For collections (MCP): - Provide `env` in params for tool environment variables - Owner's stored credentials are never shared Returns clear errors listing missing required env vars if not provided. Files changed: - packages/types/src/agent.ts: Add providerApiKey to SendMessageSchema - apps/web/src/lib/agents/env-helpers.ts: New helper functions for env vars - apps/web/src/lib/agents/build-tools.ts: Accept callerEnvVars parameter - apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts: Allow public agent access with caller credentials - apps/web/src/lib/mcp/handlers.ts: Accept callerEnvVars, validate requirements - apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts: Allow public collection access, pass isOwner flag - apps/web/src/app/docs/platform-guide/page.tsx: Update access model docs
This commit is contained in:
parent
fdb61ed010
commit
7d9321d9c6
7 changed files with 478 additions and 275 deletions
|
|
@ -117,23 +117,22 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
|||
|
||||
// Fetch agent with all tool relations using agent ID
|
||||
const { fetchAgentWithTools, buildAgentTools } = await import('@/lib/agents/build-tools');
|
||||
const { getRequiredEnvVarsForAgent, getMissingEnvVars } = await import(
|
||||
'@/lib/agents/env-helpers'
|
||||
);
|
||||
const agent = await fetchAgentWithTools(agentId);
|
||||
|
||||
if (!agent) {
|
||||
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Owner-only enforcement: Only the agent owner can chat with the agent
|
||||
if (authResult.userId !== agent.userId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
'Fork this agent to use it. Only the agent owner can chat with agents. ' +
|
||||
'Visit the agent page to fork it to your account.',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
// Check ownership
|
||||
const isOwner = authResult.userId === agent.userId;
|
||||
|
||||
// Non-owners can only access PUBLIC agents
|
||||
if (!isOwner && !agent.isPublic) {
|
||||
// Don't reveal that the agent exists - return 404
|
||||
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Map provider to expected key name format
|
||||
|
|
@ -153,29 +152,76 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
|||
);
|
||||
}
|
||||
|
||||
// Get user's API key for this provider
|
||||
const userApiKey = await prisma.userApiKey.findUnique({
|
||||
where: {
|
||||
userId_keyName: {
|
||||
userId: agent.userId,
|
||||
keyName,
|
||||
},
|
||||
},
|
||||
});
|
||||
let apiKey: string;
|
||||
let callerEnvVars: Record<string, string> | undefined;
|
||||
|
||||
if (!userApiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `No API key configured for ${agent.provider}. Please add your API key in settings.`,
|
||||
if (isOwner) {
|
||||
// Owner: use stored encrypted keys
|
||||
const userApiKey = await prisma.userApiKey.findUnique({
|
||||
where: {
|
||||
userId_keyName: {
|
||||
userId: agent.userId,
|
||||
keyName,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
});
|
||||
|
||||
if (!userApiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `No API key configured for ${agent.provider}. Please add your API key in settings.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Decrypt the API key
|
||||
apiKey = decryptApiKey(userApiKey.encryptedKey, userApiKey.keyIv);
|
||||
// callerEnvVars stays undefined - buildAgentTools will use agent's stored env vars
|
||||
} else {
|
||||
// Non-owner accessing public agent: must provide their own credentials
|
||||
if (!parsed.data.providerApiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Public agent access requires your own API key',
|
||||
details: {
|
||||
code: 'MISSING_PROVIDER_KEY',
|
||||
requiredProvider: agent.provider,
|
||||
hint: `Provide your ${agent.provider} API key in the 'providerApiKey' field`,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
apiKey = parsed.data.providerApiKey;
|
||||
callerEnvVars = parsed.data.env || {};
|
||||
|
||||
// Check for required environment variables
|
||||
const requiredEnvVars = getRequiredEnvVarsForAgent(agent);
|
||||
const missingEnvVars = getMissingEnvVars(requiredEnvVars, callerEnvVars);
|
||||
|
||||
if (missingEnvVars.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing required environment variables',
|
||||
details: {
|
||||
code: 'MISSING_ENV_VARS',
|
||||
missingVars: missingEnvVars.map((e) => ({
|
||||
name: e.name,
|
||||
description: e.description,
|
||||
})),
|
||||
hint: "Provide these variables in the 'env' field of your request",
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt the API key
|
||||
const apiKey = decryptApiKey(userApiKey.encryptedKey, userApiKey.keyIv);
|
||||
|
||||
// Get or create conversation
|
||||
let conversation = await prisma.conversation.findUnique({
|
||||
where: {
|
||||
|
|
@ -285,7 +331,8 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
|||
messages.push({ role: 'user', content: parsed.data.message });
|
||||
|
||||
// Build tools from agent configuration
|
||||
const tools = buildAgentTools(agent);
|
||||
// Build tools - pass callerEnvVars if non-owner accessing public agent
|
||||
const tools = buildAgentTools(agent, callerEnvVars);
|
||||
|
||||
// Get the provider model
|
||||
const model = await getProviderModel(agent.provider, agent.modelId, apiKey);
|
||||
|
|
|
|||
|
|
@ -87,7 +87,8 @@ interface JsonRpcResponse {
|
|||
async function processJsonRpcRequest(
|
||||
collectionId: string,
|
||||
collectionName: string,
|
||||
body: JsonRpcRequest
|
||||
body: JsonRpcRequest,
|
||||
isOwner: boolean
|
||||
): Promise<JsonRpcResponse> {
|
||||
const requestId = body.id ?? null;
|
||||
|
||||
|
|
@ -98,12 +99,19 @@ async function processJsonRpcRequest(
|
|||
case 'tools/list':
|
||||
return await handleToolsList(collectionId, requestId);
|
||||
|
||||
case 'tools/call':
|
||||
return await handleToolsCall(
|
||||
collectionId,
|
||||
body.params as { name: string; arguments?: Record<string, unknown> },
|
||||
requestId
|
||||
);
|
||||
case 'tools/call': {
|
||||
const params = body.params as {
|
||||
name: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
// For non-owners, use caller-provided env vars (or empty if not provided)
|
||||
// For owners, callerEnvVars is undefined so handleToolsCall uses stored env vars
|
||||
const callerEnvVars = isOwner ? undefined : params.env || {};
|
||||
|
||||
return await handleToolsCall(collectionId, params, requestId, callerEnvVars);
|
||||
}
|
||||
|
||||
case 'notifications/initialized':
|
||||
case 'ping':
|
||||
|
|
@ -125,7 +133,8 @@ async function processJsonRpcRequest(
|
|||
async function handleHttpTransport(
|
||||
request: NextRequest,
|
||||
collectionId: string,
|
||||
collectionName: string
|
||||
collectionName: string,
|
||||
isOwner: boolean
|
||||
): Promise<NextResponse> {
|
||||
let body: JsonRpcRequest;
|
||||
try {
|
||||
|
|
@ -137,7 +146,7 @@ async function handleHttpTransport(
|
|||
);
|
||||
}
|
||||
|
||||
const response = await processJsonRpcRequest(collectionId, collectionName, body);
|
||||
const response = await processJsonRpcRequest(collectionId, collectionName, body, isOwner);
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
|
|
@ -148,7 +157,8 @@ async function handleHttpTransport(
|
|||
async function handleSseTransport(
|
||||
request: NextRequest,
|
||||
collectionId: string,
|
||||
collectionName: string
|
||||
collectionName: string,
|
||||
isOwner: boolean
|
||||
): Promise<Response> {
|
||||
let body: JsonRpcRequest;
|
||||
try {
|
||||
|
|
@ -167,7 +177,7 @@ async function handleSseTransport(
|
|||
);
|
||||
}
|
||||
|
||||
const response = await processJsonRpcRequest(collectionId, collectionName, body);
|
||||
const response = await processJsonRpcRequest(collectionId, collectionName, body, isOwner);
|
||||
|
||||
// For SSE, we send the response as an event and then close
|
||||
const encoder = new TextEncoder();
|
||||
|
|
@ -331,7 +341,7 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
|||
|
||||
// Authorization check:
|
||||
// - Owners can always access their own collections (public or private)
|
||||
// - Non-owners can only access public collections (and must fork to use)
|
||||
// - Non-owners can access PUBLIC collections with their own env vars
|
||||
const isOwner = authResult.userId === collection.userId;
|
||||
|
||||
if (!isOwner) {
|
||||
|
|
@ -342,27 +352,15 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
|||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
// Public collection but not the owner - they need to fork it
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32403,
|
||||
message:
|
||||
'Fork this collection to use it. Only the collection owner can execute tools via MCP. ' +
|
||||
'Visit the collection page to fork it to your account.',
|
||||
},
|
||||
id: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
// Public collection - non-owners can access but must provide their own env vars
|
||||
// The env vars are validated per-tool in handleToolsCall
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
if (transport === 'sse') {
|
||||
response = await handleSseTransport(request, collection.id, collection.name);
|
||||
response = await handleSseTransport(request, collection.id, collection.name, isOwner);
|
||||
} else {
|
||||
response = await handleHttpTransport(request, collection.id, collection.name);
|
||||
response = await handleHttpTransport(request, collection.id, collection.name, isOwner);
|
||||
}
|
||||
|
||||
// Track usage for authenticated requests
|
||||
|
|
|
|||
|
|
@ -63,6 +63,15 @@ const NAV_SECTIONS = [
|
|||
{ id: 'api-usage', label: 'Usage Tracking' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Access Model',
|
||||
items: [
|
||||
{ id: 'access-model', label: 'Overview' },
|
||||
{ id: 'agent-access', label: 'Agent API Access' },
|
||||
{ id: 'collection-access', label: 'Collection MCP Access' },
|
||||
{ id: 'error-reference', label: 'Error Reference' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Reference',
|
||||
items: [
|
||||
|
|
@ -1633,36 +1642,48 @@ X-RateLimit-Reset: 1704067200`}
|
|||
</DocSection>
|
||||
|
||||
{/* ==================== ACCESS MODEL ==================== */}
|
||||
<DocSection id="access-model" title="Access Model: Fork to Use">
|
||||
<DocSection id="access-model" title="Access Model: Public Access with Your Credentials">
|
||||
<p className="text-foreground-secondary mb-6">
|
||||
TPMJS uses a <strong className="text-foreground">"fork to use"</strong>{' '}
|
||||
model. You cannot directly use someone else's public agent or collection with
|
||||
your API key—you must fork it first.
|
||||
You can access public agents and collections using your own API key—but you must
|
||||
provide <strong className="text-foreground">all required credentials</strong>{' '}
|
||||
(LLM keys, tool environment variables) in the request. The owner's stored
|
||||
credentials are never shared.
|
||||
</p>
|
||||
<div className="p-4 border border-warning/30 rounded-lg bg-warning/5 mb-6">
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
<strong className="text-warning">Important:</strong> Even with a valid API key,
|
||||
you cannot call another user's agent or collection endpoints. You'll
|
||||
receive a 403 error instructing you to fork the resource first.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<h4 className="font-semibold text-foreground mb-2">Owners</h4>
|
||||
<ul className="text-sm text-foreground-secondary space-y-1">
|
||||
<li>• Use stored credentials from dashboard</li>
|
||||
<li>• Access public and private resources</li>
|
||||
<li>• No extra parameters needed in API calls</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<h4 className="font-semibold text-foreground mb-2">Non-Owners</h4>
|
||||
<ul className="text-sm text-foreground-secondary space-y-1">
|
||||
<li>• Can only access PUBLIC resources</li>
|
||||
<li>• Must provide credentials in each request</li>
|
||||
<li>• Owner's credentials are never used</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<DocSubSection title="Why Fork to Use?">
|
||||
<DocSubSection title="Why This Model?">
|
||||
<ul className="list-disc list-inside space-y-2 text-foreground-secondary">
|
||||
<li>
|
||||
<strong className="text-foreground">Security</strong> - Owners control their own
|
||||
API keys and environment variables
|
||||
<strong className="text-foreground">Security</strong> - Owner credentials stay
|
||||
private, never shared with callers
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Cost Control</strong> - You pay for your own
|
||||
usage, not someone else's
|
||||
<strong className="text-foreground">Transparency</strong> - Callers pay for
|
||||
their own LLM/tool usage
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Customization</strong> - Forking lets you
|
||||
modify tools, prompts, and settings
|
||||
<strong className="text-foreground">Flexibility</strong> - Use public resources
|
||||
without forking
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Privacy</strong> - Your conversations and
|
||||
usage stay in your account
|
||||
<strong className="text-foreground">Control</strong> - Fork when you want to
|
||||
customize
|
||||
</li>
|
||||
</ul>
|
||||
</DocSubSection>
|
||||
|
|
@ -1670,239 +1691,164 @@ X-RateLimit-Reset: 1704067200`}
|
|||
|
||||
<DocSection id="agent-access" title="Accessing Agents via API">
|
||||
<p className="text-foreground-secondary mb-6">
|
||||
When you call the agent conversation API, strict ownership checks apply.
|
||||
Public agents can be accessed by any authenticated user who provides their own
|
||||
credentials.
|
||||
</p>
|
||||
<DocSubSection title="Can I Chat with Someone Else's Public Agent?">
|
||||
<DocSubSection title="Accessing Your Own Agent">
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
<strong className="text-foreground">No.</strong> Even if an agent is public, you
|
||||
must be the owner to use the conversation API. Attempting to call another
|
||||
user's agent returns:
|
||||
As the owner, just send your message—your stored LLM key and env vars are used:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"success": false,
|
||||
"error": "Fork this agent to use it. Only the agent owner can chat with agents. Visit the agent page to fork it to your account."
|
||||
}
|
||||
// HTTP 403 Forbidden`}
|
||||
code={`POST /api/agents/{agentId}/conversation/{conversationId}
|
||||
Authorization: Bearer YOUR_TPMJS_API_KEY
|
||||
|
||||
{
|
||||
"message": "Hello, help me with..."
|
||||
}`}
|
||||
/>
|
||||
</DocSubSection>
|
||||
<DocSubSection title="Who Pays for Agent LLM Calls?">
|
||||
<DocSubSection title="Accessing Someone Else's Public Agent">
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
The <strong className="text-foreground">agent owner</strong> always pays. The
|
||||
system uses the owner's stored LLM provider API keys (OpenAI, Anthropic,
|
||||
etc.), not the caller's.
|
||||
You must provide your own LLM provider key and any tool environment variables:
|
||||
</p>
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Since you must own the agent to use it, and the owner's API keys are
|
||||
used—you're always paying for your own usage.
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`POST /api/agents/{agentId}/conversation/{conversationId}
|
||||
Authorization: Bearer YOUR_TPMJS_API_KEY
|
||||
|
||||
{
|
||||
"message": "Hello, help me with...",
|
||||
"providerApiKey": "sk-your-openai-key...",
|
||||
"env": {
|
||||
"UNSANDBOX_API_KEY": "your-unsandbox-key",
|
||||
"FIRECRAWL_API_KEY": "your-firecrawl-key"
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</DocSubSection>
|
||||
<DocSubSection title="Missing Provider API Keys">
|
||||
<DocSubSection title="Missing Provider API Key">
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
If you haven't configured your LLM provider API key, you'll receive:
|
||||
If you don't provide <code>providerApiKey</code> for a public agent:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"success": false,
|
||||
"error": "No API key configured for OPENAI. Please add your API key in settings."
|
||||
"error": "Public agent access requires your own API key",
|
||||
"details": {
|
||||
"code": "MISSING_PROVIDER_KEY",
|
||||
"requiredProvider": "OPENAI",
|
||||
"hint": "Provide your OPENAI API key in the 'providerApiKey' field"
|
||||
}
|
||||
}
|
||||
// HTTP 400 Bad Request`}
|
||||
/>
|
||||
<p className="text-foreground-secondary mt-4">
|
||||
Add your provider keys at{' '}
|
||||
<Link
|
||||
href="/dashboard/settings/api-keys"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Dashboard → Settings → API Keys
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</DocSubSection>
|
||||
<DocSubSection title="Agent API Workflow">
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary font-bold flex items-center justify-center text-xs">
|
||||
1
|
||||
</span>
|
||||
<span className="font-medium text-foreground">Find a public agent</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-secondary ml-8">
|
||||
Browse at tpmjs.com/{'{username}'}/agents/{'{uid}'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary font-bold flex items-center justify-center text-xs">
|
||||
2
|
||||
</span>
|
||||
<span className="font-medium text-foreground">Fork it to your account</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-secondary ml-8">
|
||||
Click "Fork" to create your own copy
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary font-bold flex items-center justify-center text-xs">
|
||||
3
|
||||
</span>
|
||||
<span className="font-medium text-foreground">Add your LLM API key</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-secondary ml-8">
|
||||
Configure your provider key (not copied during fork)
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary font-bold flex items-center justify-center text-xs">
|
||||
4
|
||||
</span>
|
||||
<span className="font-medium text-foreground">
|
||||
Use your fork via API
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-secondary ml-8">
|
||||
Call /api/{'{your-username}'}/agents/{'{uid}'}/conversation/{'{id}'}
|
||||
</p>
|
||||
</div>
|
||||
<DocSubSection title="Missing Environment Variables">
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
If the agent's tools require env vars you didn't provide:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"success": false,
|
||||
"error": "Missing required environment variables",
|
||||
"details": {
|
||||
"code": "MISSING_ENV_VARS",
|
||||
"missingVars": [
|
||||
{ "name": "UNSANDBOX_API_KEY", "description": "API key for code execution" },
|
||||
{ "name": "FIRECRAWL_API_KEY", "description": "API key for web scraping" }
|
||||
],
|
||||
"hint": "Provide these variables in the 'env' field of your request"
|
||||
}
|
||||
}
|
||||
// HTTP 400 Bad Request`}
|
||||
/>
|
||||
</DocSubSection>
|
||||
<DocSubSection title="Who Pays?">
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<p className="text-foreground-secondary">
|
||||
<strong className="text-foreground">The caller always pays.</strong> When
|
||||
accessing a public agent, you provide your own LLM API key—so LLM costs go to
|
||||
your account. When accessing your own agent, your stored keys are used.
|
||||
</p>
|
||||
</div>
|
||||
</DocSubSection>
|
||||
</DocSection>
|
||||
|
||||
<DocSection id="collection-access" title="Accessing Collections via MCP">
|
||||
<p className="text-foreground-secondary mb-6">
|
||||
MCP endpoints for collections follow the same fork-to-use model.
|
||||
Public collections can be accessed by any authenticated user who provides their own
|
||||
environment variables.
|
||||
</p>
|
||||
<DocSubSection title="Can I Use Someone Else's Public Collection?">
|
||||
<DocSubSection title="Accessing Your Own Collection">
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
<strong className="text-foreground">No.</strong> Even if a collection is public,
|
||||
you must be the owner to execute tools via MCP. Attempting to call another
|
||||
user's collection returns:
|
||||
As the owner, just make MCP calls—your stored env vars are used:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"jsonrpc": "2.0",
|
||||
"error": {
|
||||
"code": -32403,
|
||||
"message": "Fork this collection to use it. Only the collection owner can execute tools via MCP. Visit the collection page to fork it to your account."
|
||||
}
|
||||
}
|
||||
// HTTP 403 Forbidden`}
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "tpmjs-unsandbox-executeCode",
|
||||
"arguments": { "code": "print('hello')" }
|
||||
},
|
||||
"id": 1
|
||||
}`}
|
||||
/>
|
||||
</DocSubSection>
|
||||
<DocSubSection title="Who Pays for Tool Execution?">
|
||||
<DocSubSection title="Accessing Someone Else's Public Collection">
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
The <strong className="text-foreground">caller</strong> (API key owner) pays for
|
||||
tool execution. Usage is tracked against your account and counts against your rate
|
||||
limits.
|
||||
You must provide environment variables in the <code>env</code> field of{' '}
|
||||
<code>params</code>:
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<h4 className="font-semibold text-foreground mb-2">You Provide</h4>
|
||||
<ul className="text-sm text-foreground-secondary space-y-1">
|
||||
<li>• Your TPMJS API key (for auth)</li>
|
||||
<li>• Your rate limit quota</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<h4 className="font-semibold text-foreground mb-2">Collection Provides</h4>
|
||||
<ul className="text-sm text-foreground-secondary space-y-1">
|
||||
<li>• Tool environment variables</li>
|
||||
<li>• Executor configuration</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DocSubSection>
|
||||
<DocSubSection title="Environment Variables">
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Tools use the <strong className="text-foreground">collection owner's</strong>{' '}
|
||||
stored environment variables—not yours. You cannot pass custom env vars via the
|
||||
MCP request.
|
||||
</p>
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
<strong className="text-foreground">Example:</strong> If you fork a web scraping
|
||||
collection, you must add your own FIRECRAWL_API_KEY in the collection settings.
|
||||
The original owner's key is not copied.
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "tpmjs-unsandbox-executeCode",
|
||||
"arguments": { "code": "print('hello')" },
|
||||
"env": {
|
||||
"UNSANDBOX_API_KEY": "your-unsandbox-key"
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
}`}
|
||||
/>
|
||||
</DocSubSection>
|
||||
<DocSubSection title="Missing Environment Variables">
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
If tools require environment variables that aren't configured, the behavior
|
||||
depends on the tool. Most tools will return an error in the result:
|
||||
If you don't provide required env vars:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"content": [{ "type": "text", "text": "Error: FIRECRAWL_API_KEY is required" }],
|
||||
"isError": true
|
||||
"error": {
|
||||
"code": -32602,
|
||||
"message": "Missing required environment variables",
|
||||
"data": {
|
||||
"missingVars": [
|
||||
{ "name": "UNSANDBOX_API_KEY", "description": "API key for code execution" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
<p className="text-foreground-secondary mt-4">
|
||||
Check each tool's required environment variables and add them in your
|
||||
collection's Env Vars tab.
|
||||
</p>
|
||||
</DocSubSection>
|
||||
<DocSubSection title="Collection MCP Workflow">
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary font-bold flex items-center justify-center text-xs">
|
||||
1
|
||||
</span>
|
||||
<span className="font-medium text-foreground">Find a public collection</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-secondary ml-8">
|
||||
Browse at tpmjs.com/{'{username}'}/collections/{'{slug}'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary font-bold flex items-center justify-center text-xs">
|
||||
2
|
||||
</span>
|
||||
<span className="font-medium text-foreground">Fork it to your account</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-secondary ml-8">
|
||||
Click "Fork" to create your own copy
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary font-bold flex items-center justify-center text-xs">
|
||||
3
|
||||
</span>
|
||||
<span className="font-medium text-foreground">Add environment variables</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-secondary ml-8">
|
||||
Configure tool API keys (not copied during fork)
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary font-bold flex items-center justify-center text-xs">
|
||||
4
|
||||
</span>
|
||||
<span className="font-medium text-foreground">
|
||||
Use your fork via MCP
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-secondary ml-8">
|
||||
Connect to /api/mcp/{'{your-username}'}/{'{slug}'}/http
|
||||
</p>
|
||||
</div>
|
||||
<DocSubSection title="Who Pays?">
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<p className="text-foreground-secondary">
|
||||
<strong className="text-foreground">The caller always pays.</strong> When
|
||||
accessing a public collection, you provide your own tool API keys—so tool costs
|
||||
(if any) go to your accounts. Rate limits are tracked against your TPMJS API
|
||||
key.
|
||||
</p>
|
||||
</div>
|
||||
</DocSubSection>
|
||||
</DocSection>
|
||||
|
|
@ -1940,13 +1886,13 @@ X-RateLimit-Reset: 1704067200`}
|
|||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="error" size="sm">
|
||||
403
|
||||
404
|
||||
</Badge>
|
||||
<code className="text-foreground font-mono text-sm">Fork Required</code>
|
||||
<code className="text-foreground font-mono text-sm">Not Found / Private</code>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
You're trying to use someone else's agent/collection. Fork it first,
|
||||
then use your own copy.
|
||||
Resource doesn't exist or is private. Non-owners can only access PUBLIC
|
||||
agents/collections.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
|
|
@ -1957,8 +1903,20 @@ X-RateLimit-Reset: 1704067200`}
|
|||
<code className="text-foreground font-mono text-sm">Missing Provider Key</code>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Agent's LLM provider key not configured. Add it at Dashboard → Settings →
|
||||
API Keys.
|
||||
For public agents: provide <code>providerApiKey</code> in your request. For your
|
||||
own agents: add your LLM key in Dashboard → Settings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="warning" size="sm">
|
||||
400
|
||||
</Badge>
|
||||
<code className="text-foreground font-mono text-sm">Missing Env Vars</code>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Tools require environment variables you didn't provide. Check the error
|
||||
details for the list of required vars.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
|
|
|
|||
|
|
@ -237,9 +237,14 @@ function mergeEnvVars(
|
|||
* - Collection env vars are used as base
|
||||
* - Agent env vars override collection env vars for same keys
|
||||
* - Both are merged together for unique keys
|
||||
*
|
||||
* @param agent - The agent with tool relations
|
||||
* @param callerEnvVars - Optional env vars provided by the caller (for non-owners accessing public agents).
|
||||
* When provided, these are used INSTEAD of the agent's stored env vars.
|
||||
*/
|
||||
export function buildAgentTools(
|
||||
agent: AgentWithRelations
|
||||
agent: AgentWithRelations,
|
||||
callerEnvVars?: Record<string, string>
|
||||
): Record<string, ReturnType<typeof createToolDefinition>> {
|
||||
const tools: Record<string, ReturnType<typeof createToolDefinition>> = {};
|
||||
const seenTools = new Set<string>();
|
||||
|
|
@ -247,8 +252,10 @@ export function buildAgentTools(
|
|||
// Parse agent-level executor config
|
||||
const agentExecutorConfig = parseExecutorConfig(agent.executorType, agent.executorConfig);
|
||||
|
||||
// Parse agent-level env vars
|
||||
const agentEnvVars = parseEnvVars(agent.envVars);
|
||||
// When callerEnvVars is provided (non-owner using public agent), use those exclusively.
|
||||
// Otherwise, use the agent's stored env vars.
|
||||
const useCallerEnvVars = callerEnvVars !== undefined;
|
||||
const agentEnvVars = useCallerEnvVars ? callerEnvVars : parseEnvVars(agent.envVars);
|
||||
|
||||
// Add tools from collections first
|
||||
for (const agentCollection of agent.collections) {
|
||||
|
|
@ -264,7 +271,8 @@ export function buildAgentTools(
|
|||
const resolvedConfig = resolveExecutorConfig(agentExecutorConfig, collectionExecutorConfig);
|
||||
|
||||
// Parse collection-level env vars and merge with agent env vars
|
||||
const collectionEnvVars = parseEnvVars(collection.envVars);
|
||||
// When callerEnvVars is provided, skip collection env vars entirely
|
||||
const collectionEnvVars = useCallerEnvVars ? {} : parseEnvVars(collection.envVars);
|
||||
const mergedEnvVars = mergeEnvVars(collectionEnvVars, agentEnvVars);
|
||||
|
||||
for (const collectionTool of collection.tools) {
|
||||
|
|
|
|||
156
apps/web/src/lib/agents/env-helpers.ts
Normal file
156
apps/web/src/lib/agents/env-helpers.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* Helper functions for working with environment variables in agents and collections
|
||||
*/
|
||||
|
||||
import type { Agent, AgentCollection, AgentTool, Collection, Package, Tool } from '@tpmjs/db';
|
||||
import { prisma } from '@tpmjs/db';
|
||||
import type { TpmjsEnv } from '@tpmjs/types/tpmjs';
|
||||
|
||||
// Reuse the AgentWithRelations type
|
||||
type AgentWithRelations = Agent & {
|
||||
collections: (AgentCollection & {
|
||||
collection: Collection & {
|
||||
tools: Array<{
|
||||
tool: Tool & { package: Package };
|
||||
}>;
|
||||
};
|
||||
})[];
|
||||
tools: (AgentTool & {
|
||||
tool: Tool & { package: Package };
|
||||
})[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all required environment variables for an agent's tools
|
||||
* Collects env vars from all tools in collections and individual tools
|
||||
*/
|
||||
export function getRequiredEnvVarsForAgent(agent: AgentWithRelations): TpmjsEnv[] {
|
||||
const envVars = new Map<string, TpmjsEnv>();
|
||||
|
||||
// Collect from collections
|
||||
for (const agentCollection of agent.collections) {
|
||||
for (const collectionTool of agentCollection.collection.tools) {
|
||||
const packageEnv = collectionTool.tool.package.env as TpmjsEnv[] | null;
|
||||
if (packageEnv && Array.isArray(packageEnv)) {
|
||||
for (const env of packageEnv) {
|
||||
if (!envVars.has(env.name)) {
|
||||
envVars.set(env.name, env);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect from individual tools
|
||||
for (const agentTool of agent.tools) {
|
||||
const packageEnv = agentTool.tool.package.env as TpmjsEnv[] | null;
|
||||
if (packageEnv && Array.isArray(packageEnv)) {
|
||||
for (const env of packageEnv) {
|
||||
if (!envVars.has(env.name)) {
|
||||
envVars.set(env.name, env);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(envVars.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get required environment variables for a specific tool in a collection
|
||||
*/
|
||||
export async function getRequiredEnvVarsForCollectionTool(
|
||||
collectionId: string,
|
||||
toolName: string
|
||||
): Promise<TpmjsEnv[]> {
|
||||
const collection = await prisma.collection.findUnique({
|
||||
where: { id: collectionId },
|
||||
include: {
|
||||
tools: {
|
||||
include: {
|
||||
tool: {
|
||||
include: { package: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!collection) return [];
|
||||
|
||||
// Parse tool name to find the matching tool
|
||||
// Tool names are in format: sanitized-package-name-toolName
|
||||
// e.g., "tpmjs-unsandbox-executeCode"
|
||||
for (const ct of collection.tools) {
|
||||
const pkgName = ct.tool.package.npmPackageName;
|
||||
// Sanitize the package name the same way as in tool-converter
|
||||
const sanitizedPkg = pkgName.replace(/[@/]/g, '-').replace(/^-+/, '');
|
||||
const expectedToolName = `${sanitizedPkg}-${ct.tool.name}`;
|
||||
|
||||
if (toolName === expectedToolName || toolName.includes(sanitizedPkg)) {
|
||||
const packageEnv = ct.tool.package.env as TpmjsEnv[] | null;
|
||||
return packageEnv || [];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all required environment variables for all tools in a collection
|
||||
*/
|
||||
export async function getRequiredEnvVarsForCollection(collectionId: string): Promise<TpmjsEnv[]> {
|
||||
const collection = await prisma.collection.findUnique({
|
||||
where: { id: collectionId },
|
||||
include: {
|
||||
tools: {
|
||||
include: {
|
||||
tool: {
|
||||
include: { package: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!collection) return [];
|
||||
|
||||
const envVars = new Map<string, TpmjsEnv>();
|
||||
|
||||
for (const ct of collection.tools) {
|
||||
const packageEnv = ct.tool.package.env as TpmjsEnv[] | null;
|
||||
if (packageEnv && Array.isArray(packageEnv)) {
|
||||
for (const env of packageEnv) {
|
||||
if (!envVars.has(env.name)) {
|
||||
envVars.set(env.name, env);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(envVars.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which required env vars are missing from provided env vars
|
||||
* Returns only the required vars that don't have defaults and aren't provided
|
||||
*/
|
||||
export function getMissingEnvVars(
|
||||
requiredEnvVars: TpmjsEnv[],
|
||||
providedEnvVars: Record<string, string>
|
||||
): TpmjsEnv[] {
|
||||
return requiredEnvVars.filter(
|
||||
(env) => env.required !== false && !env.default && !providedEnvVars[env.name]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that provided env var names are in the allowed list
|
||||
* Returns invalid names that shouldn't be accepted
|
||||
*/
|
||||
export function getInvalidEnvVarNames(
|
||||
providedEnvVars: Record<string, string>,
|
||||
allowedEnvNames: string[]
|
||||
): string[] {
|
||||
return Object.keys(providedEnvVars).filter((name) => !allowedEnvNames.includes(name));
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import type { TpmjsEnv } from '@tpmjs/types/tpmjs';
|
||||
import { queueBridgeToolCall, waitForBridgeResult } from '~/app/api/bridge/route';
|
||||
import { executeWithExecutor, parseExecutorConfig } from '../executors';
|
||||
import {
|
||||
|
|
@ -16,7 +17,7 @@ interface JsonRpcResponse {
|
|||
jsonrpc: '2.0';
|
||||
id: JsonRpcId;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string };
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -120,15 +121,19 @@ export async function handleToolsList(
|
|||
interface ToolsCallParams {
|
||||
name: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
env?: Record<string, string>; // Caller-provided env vars for non-owners
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MCP tools/call request
|
||||
* @param callerEnvVars - Optional env vars from caller (non-owner accessing public collection)
|
||||
* When provided, these are used INSTEAD of collection's stored env vars
|
||||
*/
|
||||
export async function handleToolsCall(
|
||||
collectionId: string,
|
||||
params: ToolsCallParams,
|
||||
requestId: JsonRpcId
|
||||
requestId: JsonRpcId,
|
||||
callerEnvVars?: Record<string, string>
|
||||
): Promise<JsonRpcResponse> {
|
||||
try {
|
||||
const parsed = parseToolName(params.name);
|
||||
|
|
@ -196,20 +201,49 @@ export async function handleToolsCall(
|
|||
const actualPackageName = collectionTool.tool.package.npmPackageName;
|
||||
const actualVersion = collectionTool.tool.package.npmVersion;
|
||||
|
||||
// When caller provides env vars (non-owner), validate required env vars
|
||||
if (callerEnvVars !== undefined) {
|
||||
const packageEnv = collectionTool.tool.package.env as TpmjsEnv[] | null;
|
||||
if (packageEnv && Array.isArray(packageEnv)) {
|
||||
const missingVars = packageEnv.filter(
|
||||
(env) => env.required !== false && !env.default && !callerEnvVars[env.name]
|
||||
);
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: requestId,
|
||||
error: {
|
||||
code: -32602,
|
||||
message: 'Missing required environment variables',
|
||||
data: {
|
||||
missingVars: missingVars.map((e) => ({
|
||||
name: e.name,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve executor configuration (collection config only for MCP - no agent context)
|
||||
const executorConfig = parseExecutorConfig(
|
||||
collection?.executorType,
|
||||
collection?.executorConfig
|
||||
);
|
||||
|
||||
// Execute via resolved executor with collection's environment variables
|
||||
// Execute via resolved executor
|
||||
// Use caller-provided env vars if given (non-owner), otherwise use collection's stored env vars
|
||||
const effectiveEnvVars = callerEnvVars ?? (collection?.envVars as Record<string, string>) ?? {};
|
||||
// Pass explicit version to avoid Deno HTTP import cache issues with @latest
|
||||
const result = await executeWithExecutor(executorConfig, {
|
||||
packageName: actualPackageName,
|
||||
name: parsed.toolName,
|
||||
version: actualVersion,
|
||||
params: params.arguments ?? {},
|
||||
env: (collection?.envVars as Record<string, string>) ?? undefined,
|
||||
env: Object.keys(effectiveEnvVars).length > 0 ? effectiveEnvVars : undefined,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ export const CreateConversationSchema = z.object({
|
|||
export const SendMessageSchema = z.object({
|
||||
message: z.string().min(1, 'Message is required').max(50000, 'Message too long'),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
// For non-owners accessing public agents: provide your own LLM API key
|
||||
providerApiKey: z.string().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue