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:
Ajax Davis 2026-01-16 14:59:00 +10:00
parent fdb61ed010
commit 7d9321d9c6
7 changed files with 478 additions and 275 deletions

View file

@ -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);

View file

@ -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

View file

@ -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">&quot;fork to use&quot;</strong>{' '}
model. You cannot directly use someone else&apos;s public agent or collection with
your API keyyou must fork it first.
You can access public agents and collections using your own API keybut you must
provide <strong className="text-foreground">all required credentials</strong>{' '}
(LLM keys, tool environment variables) in the request. The owner&apos;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&apos;s agent or collection endpoints. You&apos;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&apos;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&apos;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&apos;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&apos;s agent returns:
As the owner, just send your messageyour 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&apos;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&apos;s stored LLM provider API keys (OpenAI, Anthropic,
etc.), not the caller&apos;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&apos;s API keys are
usedyou&apos;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&apos;t configured your LLM provider API key, you&apos;ll receive:
If you don&apos;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 &quot;Fork&quot; 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&apos;s tools require env vars you didn&apos;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 keyso 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&apos;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&apos;s collection returns:
As the owner, just make MCP callsyour 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&apos;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&apos;s</strong>{' '}
stored environment variablesnot 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&apos;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&apos;t configured, the behavior
depends on the tool. Most tools will return an error in the result:
If you don&apos;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&apos;s required environment variables and add them in your
collection&apos;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 &quot;Fork&quot; 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 keysso 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&apos;re trying to use someone else&apos;s agent/collection. Fork it first,
then use your own copy.
Resource doesn&apos;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&apos;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&apos;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">

View file

@ -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) {

View 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));
}

View file

@ -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) {

View file

@ -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(),
});
// ============================================================================