Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions server/src/addie/mcp/member-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import { issueDomainChallenge, verifyDomainChallenge } from '../../services/bran
import { getWorkos } from '../../auth/workos-client.js';
import { resolveUserRole } from '../../utils/resolve-user-role.js';
import { recordAgentTestRun } from '../../db/agent-test-db.js';
import { canonicalizeAgentUrl } from '../../db/publisher-db.js';

const memberDb = new MemberDatabase();
const agentContextDb = new AgentContextDatabase();
Expand Down Expand Up @@ -5653,15 +5654,29 @@ export function createMemberToolHandlers(
return 'This feature requires an organization. Visit https://agenticadvertising.org/onboarding to create one (free, takes 2 minutes). You can still use the public test agent directly via `evaluate_agent_quality` without an organization.';
}

const agentUrl = input.agent_url as string;
const rawAgentUrl = input.agent_url as string;
try {
const parsed = new URL(agentUrl);
const parsed = new URL(rawAgentUrl);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return 'Agent URL must use https:// or http:// protocol.';
}
} catch {
return 'Invalid agent URL format. Please provide a full URL like https://your-agent.example.com';
}
// Canonicalize so this writes the same row as the REST POST /api/me/agents
// path (issue #3573). Query strings and fragments are rejected at the
// boundary; canonicalizeAgentUrl itself preserves them.
if (rawAgentUrl.includes('?') || rawAgentUrl.includes('#')) {
return 'Agent URL must not contain query strings or fragments.';
}
const canonical = canonicalizeAgentUrl(rawAgentUrl);
if (!canonical) {
return 'Invalid agent URL format. Please provide a full URL like https://your-agent.example.com';
}
// Bind to a typed local so closures (ensureAgentInProfile) see `string`
// rather than `string | null` — TS can't carry the narrowing across the
// function boundary.
const agentUrl: string = canonical;
const agentName = input.agent_name as string | undefined;
const authToken = input.auth_token as string | undefined;
if (authToken !== undefined) {
Expand Down Expand Up @@ -5737,7 +5752,9 @@ export function createMemberToolHandlers(
}

const agents = profile.agents || [];
const existing = agents.find((a: any) => a.url === agentUrl);
// Match in canonical form so a legacy non-canonical row gets
// updated in place rather than duplicated (issue #3573).
const existing = agents.find((a: any) => (canonicalizeAgentUrl(a.url) ?? a.url) === agentUrl);
if (!existing) {
// Default to members_only, not public. The public directory
// requires an API-access tier (Professional+); defaulting to
Expand Down Expand Up @@ -5947,7 +5964,11 @@ export function createMemberToolHandlers(
return 'This feature requires an organization. Visit https://agenticadvertising.org/onboarding to create one (free, takes 2 minutes). You can still use the public test agent directly via `evaluate_agent_quality` without an organization.';
}

const agentUrl = input.agent_url as string;
const rawAgentUrl = input.agent_url as string;
// Canonicalize so this matches whatever shape save_agent / POST
// /api/me/agents wrote (issue #3573). A fallback to the raw URL keeps
// legacy non-canonical rows reachable for removal.
const agentUrl = canonicalizeAgentUrl(rawAgentUrl) ?? rawAgentUrl;

type ProfileRemoveStatus =
| { ok: true; removedFromProfile: boolean; agentName: string | null }
Expand All @@ -5964,12 +5985,14 @@ export function createMemberToolHandlers(
const profile = await memberDb.getProfileByOrgId(removeOrgId);
if (!profile) return { ok: true, removedFromProfile: false, agentName: null };
const agents = profile.agents || [];
const existing = agents.find((a: any) => a.url === agentUrl);
// Match existing rows in canonical form so a legacy non-canonical
// entry is reachable for removal (issue #3573).
const existing = agents.find((a: any) => (canonicalizeAgentUrl(a.url) ?? a.url) === agentUrl);
if (!existing) return { ok: true, removedFromProfile: false, agentName: null };
if ((existing as any).visibility === 'public') {
return { ok: false, reason: 'public' };
}
const next = agents.filter((a: any) => a.url !== agentUrl);
const next = agents.filter((a: any) => (canonicalizeAgentUrl(a.url) ?? a.url) !== agentUrl);
await memberDb.updateProfile(profile.id, { agents: next });
return { ok: true, removedFromProfile: true, agentName: (existing as any).name ?? null };
} catch (err) {
Expand Down
31 changes: 24 additions & 7 deletions server/src/federated-index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FederatedIndexDatabase, type AgentPublisherAuthorization, type DiscoveredProperty, type PropertyIdentifier, type PublisherPropertySelector } from './db/federated-index-db.js';
import { MemberDatabase } from './db/member-db.js';
import { canonicalizeAgentUrl } from './db/publisher-db.js';
import type { FederatedAgent, FederatedPublisher, DomainLookupResult, AgentType } from './types.js';

/**
Expand Down Expand Up @@ -55,8 +56,13 @@ export class FederatedIndexService {
const agentType = agentConfig.type || 'unknown';
if (type && agentType !== type) continue;

registeredAgents.set(agentConfig.url, {
url: agentConfig.url,
// Canonicalize the map key so two registrations differing only in
// case / trailing slash collapse to a single entry (issue #3573).
// Fall back to the raw url if canonicalization rejects (legacy
// whitespace etc.) so we never silently drop a stored agent.
const key = canonicalizeAgentUrl(agentConfig.url) ?? agentConfig.url;
registeredAgents.set(key, {
url: key,
name: agentConfig.name || profile.display_name,
type: agentType as FederatedAgent['type'],
protocol: 'mcp',
Expand Down Expand Up @@ -114,10 +120,15 @@ export class FederatedIndexService {
const profiles = await this.memberDb.listProfiles({});
const registeredAgentUrls = new Map<string, { slug: string; display_name: string }>();

// Key the enrichment map on canonical form (issue #3573) so a registered
// `https://Example.com/` matches a discovered `https://example.com`.
// Fall back to the raw url if canonicalization rejects so legacy
// non-canonical rows still enrich.
for (const profile of profiles) {
for (const agentConfig of profile.agents || []) {
if (agentConfig.visibility === 'public') {
registeredAgentUrls.set(agentConfig.url, {
const key = canonicalizeAgentUrl(agentConfig.url) ?? agentConfig.url;
registeredAgentUrls.set(key, {
slug: profile.slug,
display_name: profile.display_name,
});
Expand All @@ -130,7 +141,8 @@ export class FederatedIndexService {
const authorizedAgents = authorizations
.filter(auth => auth.source === 'adagents_json')
.map(auth => {
const member = registeredAgentUrls.get(auth.agent_url);
const lookupKey = canonicalizeAgentUrl(auth.agent_url) ?? auth.agent_url;
const member = registeredAgentUrls.get(lookupKey);
return {
url: auth.agent_url,
authorized_for: auth.authorized_for,
Expand All @@ -141,7 +153,8 @@ export class FederatedIndexService {
// Get sales agents claiming this domain
const claims = await this.db.getSalesAgentsClaimingDomain(domain);
const salesAgentsClaiming = claims.map(claim => {
const member = registeredAgentUrls.get(claim.discovered_by_agent);
const lookupKey = canonicalizeAgentUrl(claim.discovered_by_agent) ?? claim.discovered_by_agent;
const member = registeredAgentUrls.get(lookupKey);
return {
url: claim.discovered_by_agent,
...(member ? { member } : {}),
Expand Down Expand Up @@ -190,11 +203,15 @@ export class FederatedIndexService {
async getAllAgentDomainPairs(): Promise<Map<string, Set<string>>> {
const pairs = await this.db.getAllAgentDomainPairs();
const result = new Map<string, Set<string>>();
// Canonicalize so legacy raw rows in the DB (the write path stores
// verbatim; SQL canonicalizes on read but not on bulk fetch) collapse
// into one key when a caller looks up by canonical form (issue #3573).
for (const { agent_url, publisher_domain } of pairs) {
let domains = result.get(agent_url);
const key = canonicalizeAgentUrl(agent_url) ?? agent_url;
let domains = result.get(key);
if (!domains) {
domains = new Set();
result.set(agent_url, domains);
result.set(key, domains);
}
domains.add(publisher_domain);
}
Expand Down
64 changes: 50 additions & 14 deletions server/src/routes/member-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import { resolvePrimaryOrganization } from '../db/users-db.js';
import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js';
import { getPool } from '../db/client.js';
import { canonicalizeAgentUrl } from '../db/publisher-db.js';
import type { AgentConfig } from '../types.js';
import { isValidAgentType } from '../types.js';
import { resolveAgentTypes, logResolvedTypeChanges } from './member-profiles.js';
Expand Down Expand Up @@ -319,8 +320,12 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
// lifecycle_stage / check_interval_hours / opt-out the heartbeat or
// dashboard wrote earlier; we only seed the row when it doesn't
// exist. Defaults inherit from the column DDL.
// Canonicalize before seeding the metadata table. Handlers above
// already canonicalize, but this keeps any future write site honest
// and matches the canonical-form invariant the rest of the registry
// relies on (issue #3573).
const urls = typed
.map(a => (a && typeof a.url === 'string' ? a.url : null))
.map(a => (a && typeof a.url === 'string' ? canonicalizeAgentUrl(a.url) : null))
.filter((u): u is string => u !== null);
if (urls.length > 0) {
await client.query(
Expand Down Expand Up @@ -405,6 +410,16 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
if (!isParseableUrl(body.url)) {
return res.status(400).json({ error: 'url must be a valid URL' });
}
// Query strings and fragments have no place in agent identity (issue
// #3573). Reject at the boundary — `canonicalizeAgentUrl` itself
// preserves them verbatim, so the check belongs here.
if (body.url.includes('?') || body.url.includes('#')) {
return res.status(400).json({ error: 'url must not contain query strings or fragments' });
}
const canonicalUrl = canonicalizeAgentUrl(body.url);
if (!canonicalUrl) {
return res.status(400).json({ error: 'url is not a valid agent URL' });
}
// `type` is required from the caller — never inferred. 'unknown' is
// reserved for server-side smuggle protection (resolveAgentTypes), not
// for client input. The caller MUST declare what kind of agent this is.
Expand All @@ -414,7 +429,10 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
message: 'Specify one of: brand, rights, measurement, governance, creative, sales, buying, signals.',
});
}
const targetUrl = body.url;
// Persist and compare in canonical form so the registered side
// collapses with the discovered side (issue #3573).
body.url = canonicalUrl;
const targetUrl = canonicalUrl;

// Auto-bootstrap a private member profile if the caller's org doesn't
// have one yet. Reuses `ensureMemberProfileExists` (the same helper
Expand All @@ -440,7 +458,9 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
}

const result = await applyMemberAgentMutation(orgId, (existing) => {
const idx = existing.findIndex((a) => a.url === targetUrl);
// Match existing rows in canonical form so a legacy non-canonical
// entry (pre-#3573) gets upgraded in place rather than duplicated.
const idx = existing.findIndex((a) => (canonicalizeAgentUrl(a.url) ?? a.url) === targetUrl);
const isUpdate = idx !== -1;
const next = isUpdate
? existing.map((a, i) => (i === idx ? { ...a, ...body } : a))
Expand Down Expand Up @@ -470,16 +490,26 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
if (!orgId) return;

// Express already URL-decodes path params; do not double-decode.
const targetUrl = req.params.url;
// Canonicalize so a member submitting `HTTPS://Example.com/` matches
// the row stored canonically (issue #3573).
const targetUrl = canonicalizeAgentUrl(req.params.url);
if (!targetUrl) {
return res.status(400).json({ error: 'url is not a valid agent URL' });
}
const patch = (req.body ?? {}) as Partial<AgentConfig>;

// Refuse to silently drop a `url` rename. Tell the caller; never guess.
if (typeof patch.url === 'string' && patch.url !== targetUrl) {
return res.status(400).json({
error: 'url_immutable',
message:
'url cannot be changed via PATCH. DELETE the old entry and POST the new url.',
});
// Compare in canonical form so `https://Example.com/` in the path and
// `https://example.com` in the body aren't flagged as a rename.
if (typeof patch.url === 'string') {
const patchCanonical = canonicalizeAgentUrl(patch.url);
if (patchCanonical !== targetUrl) {
return res.status(400).json({
error: 'url_immutable',
message:
'url cannot be changed via PATCH. DELETE the old entry and POST the new url.',
});
}
}
// If `type` is being patched, it must be a valid declared type. 'unknown'
// is server-side-only. Omitting `type` from the patch is fine — the
Expand All @@ -494,7 +524,8 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
}

const result = await applyMemberAgentMutation(orgId, (existing) => {
const idx = existing.findIndex((a) => a.url === targetUrl);
// Canonical-form match so a legacy non-canonical row is still found.
const idx = existing.findIndex((a) => (canonicalizeAgentUrl(a.url) ?? a.url) === targetUrl);
if (idx === -1) {
return {
kind: 'reject' as const,
Expand All @@ -521,10 +552,15 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
if (!orgId) return;

// Express already URL-decodes path params; do not double-decode.
const targetUrl = req.params.url;
// Canonicalize so non-canonical url-encoded paths still match the
// canonical row stored on disk (issue #3573).
const targetUrl = canonicalizeAgentUrl(req.params.url);
if (!targetUrl) {
return res.status(400).json({ error: 'url is not a valid agent URL' });
}

const result = await applyMemberAgentMutation(orgId, (existing) => {
const idx = existing.findIndex((a) => a.url === targetUrl);
const idx = existing.findIndex((a) => (canonicalizeAgentUrl(a.url) ?? a.url) === targetUrl);
if (idx === -1) {
return {
kind: 'reject' as const,
Expand All @@ -551,7 +587,7 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
}
return {
kind: 'commit' as const,
next: existing.filter((a) => a.url !== targetUrl),
next: existing.filter((a) => (canonicalizeAgentUrl(a.url) ?? a.url) !== targetUrl),
status: 204,
};
});
Expand Down
24 changes: 24 additions & 0 deletions server/src/routes/member-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { MemberDatabase } from "../db/member-db.js";
import { BrandDatabase, resolveBrandFromJson } from "../db/brand-db.js";
import { BrandManager } from "../brand-manager.js";
import { OrganizationDatabase, hasApiAccess, readMembershipTierFromClient, resolveMembershipTier, VALID_REVENUE_TIERS, VALID_MEMBERSHIP_TIERS } from "../db/organization-db.js";
import { canonicalizeAgentUrl } from "../db/publisher-db.js";
import { OrgKnowledgeDatabase } from "../db/org-knowledge-db.js";
import { linkDomain } from "../db/organization-domains-db.js";
import { autoLinkByVerifiedDomain } from "../db/membership-db.js";
Expand Down Expand Up @@ -1140,6 +1141,29 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
// the POST create path via gateAgentVisibilityForCaller.
let warnings: VisibilityWarning[] = [];
if (Array.isArray(updates.agents)) {
// Canonicalize every agent url before any downstream processing
// (issue #3573). The per-agent POST/PATCH path canonicalizes at
// the handler boundary; the bulk path must match so the same write
// applied via two surfaces lands as the same row.
for (let i = 0; i < updates.agents.length; i++) {
const a = updates.agents[i] as AgentConfig & { url?: unknown };
if (a && typeof a.url === 'string') {
if (a.url.includes('?') || a.url.includes('#')) {
return res.status(400).json({
error: 'invalid_agent_url',
message: `agents[${i}].url must not contain query strings or fragments`,
});
}
const canonical = canonicalizeAgentUrl(a.url);
if (!canonical) {
return res.status(400).json({
error: 'invalid_agent_url',
message: `agents[${i}].url is not a valid agent URL`,
});
}
a.url = canonical;
}
}
const localOrgForTier = await orgDb.getOrganization(targetOrgId);
const callerHasApi = hasApiAccess(resolveMembershipTier(localOrgForTier));
const gated = gateAgentVisibilityForCaller(updates.agents, callerHasApi);
Expand Down
Loading
Loading